From e7d50509ddd9b33c3924408f35018cbe754048ae Mon Sep 17 00:00:00 2001 From: OmniLab Team Date: Thu, 27 Aug 2026 08:27:50 -0700 Subject: [PATCH] Internal change PiperOrigin-RevId: 971968111 --- .../fe/v6/angular/app/app.ng.html | 32 +- .../mobileharness/fe/v6/angular/app/app.scss | 35 +- .../fe/v6/angular/app/app.spec.ts | 55 + .../mobileharness/fe/v6/angular/app/app.ts | 31 +- .../fe/v6/angular/app/app_config.ts | 15 +- .../fe/v6/angular/app/app_routes.ts | 51 +- .../fe/v6/angular/app/core/models/search.ts | 188 ++++ .../angular/app/core/models/search_common.ts | 121 +++ .../angular/app/core/models/search_fleet.ts | 332 ++++++ .../v6/angular/app/core/models/search_tjs.ts | 122 +++ .../app/core/services/mock_data/index.ts | 1 + .../mock_data/search/fleet_scenarios.ts | 441 ++++++++ .../core/services/mock_data/search/index.ts | 2 + .../mock_data/search/tjs_scenarios.ts | 502 +++++++++ .../services/search/fake_search_service.ts | 309 ++++++ .../services/search/http_search_service.ts | 131 +++ .../core/services/search/search_service.ts | 80 ++ .../host_overview/host_overview.ng.html | 49 +- .../components/host_overview/host_overview.ts | 4 +- .../advanced_match_view.ng.html | 59 ++ .../advanced_match_view.scss | 172 ++++ .../advanced_match_view.ts | 67 ++ .../filter_value_picker.ng.html | 344 +++++++ .../filter_value_picker.scss | 615 ++++++++++++ .../filter_value_picker.ts | 330 ++++++ .../filter_presets/filter_presets.ng.html | 43 + .../filter_presets/filter_presets.scss | 98 ++ .../filter_presets/filter_presets.ts | 49 + .../components/search_box/search_box.ng.html | 5 + .../components/search_box/search_box.scss | 10 + .../components/search_box/search_box.ts | 37 + .../search_input/search_input.ng.html | 86 ++ .../search_box/search_input/search_input.scss | 224 +++++ .../search_box/search_input/search_input.ts | 236 +++++ .../search_suggestions.ng.html | 28 + .../search_suggestions.scss | 69 ++ .../search_suggestions/search_suggestions.ts | 34 + .../density_dropdown/density_dropdown.ng.html | 40 + .../density_dropdown/density_dropdown.scss | 62 ++ .../density_dropdown/density_dropdown.ts | 33 + .../common/search_cell/search_cell.ng.html | 77 ++ .../common/search_cell/search_cell.scss | 45 + .../common/search_cell/search_cell.ts | 51 + .../search_pagination.ng.html | 43 + .../search_pagination/search_pagination.scss | 169 ++++ .../search_pagination/search_pagination.ts | 90 ++ .../fleet_group_card/fleet_group_card.ng.html | 151 +++ .../fleet_group_card/fleet_group_card.scss | 403 ++++++++ .../fleet_group_card/fleet_group_card.ts | 115 +++ .../fleet_search_results.ng.html | 300 ++++++ .../fleet_search_results.scss | 242 +++++ .../fleet_search_results.ts | 332 ++++++ .../tjs_search_results.ng.html | 63 ++ .../tjs_search_results.scss | 62 ++ .../tjs_search_results/tjs_search_results.ts | 71 ++ .../app/features/search_page/models/index.ts | 2 + .../search_page/models/search_models.ts | 114 +++ .../search_page/models/value_picker_models.ts | 97 ++ .../features/search_page/search_page.ng.html | 44 + .../app/features/search_page/search_page.scss | 211 ++++ .../app/features/search_page/search_page.ts | 62 ++ .../services/fleet_search_store.ts | 949 ++++++++++++++++++ .../search_page/services/search_page_store.ts | 812 +++++++++++++++ .../search_page/services/tjs_search_store.ts | 821 +++++++++++++++ .../app/features/search_page/utils/index.ts | 4 + .../utils/search_cell_utils.spec.ts | 120 +++ .../search_page/utils/search_cell_utils.ts | 194 ++++ .../search_page/utils/search_filter_utils.ts | 623 ++++++++++++ .../search_page/utils/search_pipes.ts | 82 ++ .../utils/value_picker_utils.spec.ts | 212 ++++ .../search_page/utils/value_picker_utils.ts | 260 +++++ .../session_detail/session_detail.scss | 7 +- .../overflow_chip_list.ng.html | 35 + .../overflow_chip_list.scss | 62 ++ .../overflow_chip_list/overflow_chip_list.ts | 198 ++++ .../overflow_list/overflow_list.ng.html | 15 - .../overflow_list/overflow_list.scss | 26 - .../components/overflow_list/overflow_list.ts | 50 - .../searchable_list_overlay.ng.html | 2 +- .../searchable_list_overlay.scss | 24 + .../shared/styles/search_table_common.scss | 279 +++++ .../v6/angular/app/shared/utils/date_utils.ts | 17 + 82 files changed, 12502 insertions(+), 176 deletions(-) create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/models/search.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/models/search_common.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/models/search_fleet.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/models/search_tjs.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/fleet_scenarios.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/index.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/tjs_scenarios.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/services/search/fake_search_service.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/services/search/http_search_service.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/core/services/search/search_service.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/index.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/search_models.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/value_picker_models.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/fleet_search_store.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/search_page_store.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/tjs_search_store.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/index.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_cell_utils.spec.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_cell_utils.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_filter_utils.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_pipes.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/value_picker_utils.spec.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/value_picker_utils.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.ng.html create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.scss create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.ts delete mode 100644 src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.ng.html delete mode 100644 src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.scss delete mode 100644 src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.ts create mode 100644 src/devtools/mobileharness/fe/v6/angular/app/shared/styles/search_table_common.scss diff --git a/src/devtools/mobileharness/fe/v6/angular/app/app.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/app.ng.html index 4df1982fcb..f17af266d6 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/app.ng.html +++ b/src/devtools/mobileharness/fe/v6/angular/app/app.ng.html @@ -38,16 +38,16 @@ } - + - +
home @@ -58,10 +58,9 @@ devices @@ -73,7 +72,7 @@ [routerLink]="isFakeData ? '/dev/device-harness' : '/hosts'" [fragment]="isFakeData ? 'host-scenarios' : undefined" [class.active]="isNavActive('hosts')" - queryParamsHandling="preserve" + [queryParams]="getPreservedQueryParams()" [matTooltip]="'Hosts'" > dns @@ -84,10 +83,9 @@ fact_check @@ -96,17 +94,23 @@ task Jobs - + view_timeline Sessions diff --git a/src/devtools/mobileharness/fe/v6/angular/app/app.scss b/src/devtools/mobileharness/fe/v6/angular/app/app.scss index afc479de9e..08080d24b6 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/app.scss +++ b/src/devtools/mobileharness/fe/v6/angular/app/app.scss @@ -338,7 +338,6 @@ app-root { height: 100vh; background-color: $labconsole-bg; - $sidenav-collapsed-width: 62px; $sidenav-expanded-width: 256px; $nav-transition-duration: 250ms; @@ -464,13 +463,26 @@ app-root { flex-direction: column; height: 100%; background-color: $labconsole-bg; + overflow-x: hidden; @include common.shared-scrollbar-style(); main { flex: 1; - overflow: auto; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden auto; + width: 100%; + max-width: 100%; + box-sizing: border-box; background-color: $labconsole-bg; @include common.shared-scrollbar-style(); + + > * { + width: 100%; + max-width: 100%; + box-sizing: border-box; + } } } @@ -481,25 +493,6 @@ app-root { container-width: $sidenav-expanded-width, ) ); - - &.collapsed { - @include mat.sidenav-overrides( - ( - container-divider-color: $border-menu, - container-width: $sidenav-collapsed-width, - ) - ); - - mat-divider { - margin: 16px 0; - border-color: transparent; - } - - mat-nav-list > a[mat-list-item] { - border-radius: 0; - margin: 0; - } - } } .embedded-content { diff --git a/src/devtools/mobileharness/fe/v6/angular/app/app.spec.ts b/src/devtools/mobileharness/fe/v6/angular/app/app.spec.ts index 94a098db3d..922050ff36 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/app.spec.ts +++ b/src/devtools/mobileharness/fe/v6/angular/app/app.spec.ts @@ -63,4 +63,59 @@ describe('App Component', () => { expect(component).toBeTruthy(); }); + it('should have sideNavExpanded true by default', () => { + expect(component.sideNavExpanded).toBeTrue(); + }); + + describe('Standalone mode', () => { + let standaloneComponent: App; + let standaloneFixture: ComponentFixture; + + beforeEach(async () => { + const standaloneMockActivatedRoute = { + snapshot: { + queryParams: {}, + queryParamMap: convertToParamMap({'is_embedded_mode': 'false'}), + } as unknown as ActivatedRoute['snapshot'], + queryParamMap: of(convertToParamMap({'is_embedded_mode': 'false'})), + }; + + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, App], + providers: [ + provideRouter([]), + {provide: APP_DATA, useValue: appData}, + {provide: UrlService, useValue: mockUrlService}, + {provide: ActivatedRoute, useValue: standaloneMockActivatedRoute}, + ], + }).compileComponents(); + + standaloneFixture = TestBed.createComponent(App); + standaloneComponent = standaloneFixture.componentInstance; + standaloneFixture.detectChanges(); + }); + + it('should be in standalone mode with expanded sideNav by default', () => { + expect(standaloneComponent.isStandaloneMode).toBeTrue(); + expect(standaloneComponent.sideNavExpanded).toBeTrue(); + const sidenav = + standaloneFixture.nativeElement.querySelector('mat-sidenav'); + expect(sidenav).toBeTruthy(); + }); + + it('should toggle sideNavExpanded when menu button is clicked', () => { + const toggleButton: HTMLButtonElement = + standaloneFixture.nativeElement.querySelector('.toggleSidenavButton'); + expect(toggleButton).toBeTruthy(); + + toggleButton.click(); + standaloneFixture.detectChanges(); + expect(standaloneComponent.sideNavExpanded).toBeFalse(); + + toggleButton.click(); + standaloneFixture.detectChanges(); + expect(standaloneComponent.sideNavExpanded).toBeTrue(); + }); + }); }); diff --git a/src/devtools/mobileharness/fe/v6/angular/app/app.ts b/src/devtools/mobileharness/fe/v6/angular/app/app.ts index e06ce2e0a3..b097f2004a 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/app.ts +++ b/src/devtools/mobileharness/fe/v6/angular/app/app.ts @@ -73,7 +73,7 @@ import {filter, takeUntil} from 'rxjs/operators'; ], }) export class App implements OnDestroy { - sideNavExpanded = false; + sideNavExpanded = true; private readonly destroy = new ReplaySubject(); private readonly route: ActivatedRoute = inject(ActivatedRoute); private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef); @@ -91,7 +91,7 @@ export class App implements OnDestroy { } isNavActive( - section: 'home' | 'devices' | 'hosts' | 'tests' | 'jobs', + section: 'home' | 'devices' | 'hosts' | 'tests' | 'jobs' | 'sessions', ): boolean { const path = this.getCurrentRoutePath(); const fragment = this.router.parseUrl(this.router.url).fragment; @@ -110,11 +110,28 @@ export class App implements OnDestroy { (path.startsWith('jobs') && !path.includes('tests')) || fragment === 'job-scenarios' ); + case 'sessions': + return path.startsWith('sessions') || fragment === 'session-scenarios'; default: return false; } } + getPreservedQueryParams() { + const qParams: Record = {}; + if (this.isFakeData) { + qParams['fake_data'] = 'true'; + } + if (this.isEmbeddedMode) { + qParams['is_embedded_mode'] = 'true'; + } + const universe = this.route.snapshot.queryParams['universe']; + if (universe) { + qParams['universe'] = universe; + } + return qParams; + } + private getCurrentRoutePath(): string { let route = this.router.routerState.snapshot.root; while (route.firstChild) { @@ -162,7 +179,15 @@ export class App implements OnDestroy { const path = route.routeConfig?.path; const params = route.params; - if (path === 'dev/device-harness' || path === 'devices' || !path) { + if ( + !path || + path === 'dev/device-harness' || + path === 'devices' || + path === 'hosts' || + path === 'tests' || + path === 'jobs' || + path === 'sessions' + ) { this.showContent = true; } else if (path === 'devices/:id' && params['id']) { this.showContent = true; diff --git a/src/devtools/mobileharness/fe/v6/angular/app/app_config.ts b/src/devtools/mobileharness/fe/v6/angular/app/app_config.ts index 9a8c27d9e7..e0f946ad2c 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/app_config.ts +++ b/src/devtools/mobileharness/fe/v6/angular/app/app_config.ts @@ -6,7 +6,7 @@ import { } from '@angular/core'; import {MatSnackBarModule} from '@angular/material/snack-bar'; import {provideAnimations} from '@angular/platform-browser/animations'; -import {ActivatedRoute, provideRouter} from '@angular/router'; +import {ActivatedRoute, provideRouter, withRouterConfig} from '@angular/router'; import {routes} from './app_routes'; import {authInterceptor} from './core/interceptors/auth_interceptor'; @@ -26,6 +26,9 @@ import {InterceptedFakeHostService} from './core/services/host/intercepted_fake_ import {FakeJobService} from './core/services/job/fake_job_service'; import {HttpJobService} from './core/services/job/http_job_service'; import {JOB_SERVICE} from './core/services/job/job_service'; +import {FakeSearchService} from './core/services/search/fake_search_service'; +import {HttpSearchService} from './core/services/search/http_search_service'; +import {SEARCH_SERVICE} from './core/services/search/search_service'; import {FakeSessionService} from './core/services/session/fake_session_service'; import {HttpSessionService} from './core/services/session/http_session_service'; import {SESSION_SERVICE} from './core/services/session/session_service'; @@ -40,7 +43,7 @@ export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), importProvidersFrom(MatSnackBarModule), - provideRouter(routes), + provideRouter(routes, withRouterConfig({onSameUrlNavigation: 'reload'})), provideHttpClient( withInterceptors([ authInterceptor, @@ -103,6 +106,14 @@ export const appConfig: ApplicationConfig = { }, deps: [ActivatedRoute], }, + { + provide: SEARCH_SERVICE, + useFactory: (route: ActivatedRoute) => { + const useFakeData = route.snapshot.queryParams['fake_data'] === 'true'; + return useFakeData ? new FakeSearchService() : new HttpSearchService(); + }, + deps: [ActivatedRoute], + }, provideAnimations(), { provide: APP_DATA, diff --git a/src/devtools/mobileharness/fe/v6/angular/app/app_routes.ts b/src/devtools/mobileharness/fe/v6/angular/app/app_routes.ts index 30759a7401..eb1e56989d 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/app_routes.ts +++ b/src/devtools/mobileharness/fe/v6/angular/app/app_routes.ts @@ -4,6 +4,10 @@ import {DevHarnessPage} from './features/dev_harness/dev_harness_page'; import {DeviceDetailPage} from './features/device_detail/device_detail_page'; import {HostDetail} from './features/host_detail/host_detail'; import {JobDetail} from './features/job_detail/job_detail'; +import {SearchPage} from './features/search_page/search_page'; +import {FleetSearchStore} from './features/search_page/services/fleet_search_store'; +import {SearchPageStore} from './features/search_page/services/search_page_store'; +import {TjsSearchStore} from './features/search_page/services/tjs_search_store'; import {SessionDetailPage} from './features/session_detail/session_detail'; import {TestDetail} from './features/test_detail/test_detail'; @@ -33,9 +37,54 @@ export const routes: Routes = [ path: 'dev/device-harness', component: DevHarnessPage, }, + { + path: 'devices', + component: SearchPage, + data: {'entity': 'devices'}, + providers: [ + FleetSearchStore, + {provide: SearchPageStore, useExisting: FleetSearchStore}, + ], + }, + { + path: 'hosts', + component: SearchPage, + data: {'entity': 'hosts'}, + providers: [ + FleetSearchStore, + {provide: SearchPageStore, useExisting: FleetSearchStore}, + ], + }, + { + path: 'tests', + component: SearchPage, + data: {'entity': 'tests'}, + providers: [ + TjsSearchStore, + {provide: SearchPageStore, useExisting: TjsSearchStore}, + ], + }, + { + path: 'jobs', + component: SearchPage, + data: {'entity': 'jobs'}, + providers: [ + TjsSearchStore, + {provide: SearchPageStore, useExisting: TjsSearchStore}, + ], + }, + { + path: 'sessions', + component: SearchPage, + data: {'entity': 'sessions'}, + providers: [ + TjsSearchStore, + {provide: SearchPageStore, useExisting: TjsSearchStore}, + ], + }, { path: 'devices/:id', - component: DeviceDetailPage, // Changed from loadComponent + component: DeviceDetailPage, }, { path: 'hosts/:hostName', diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/models/search.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/models/search.ts new file mode 100644 index 0000000000..f99cc3bfbf --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/models/search.ts @@ -0,0 +1,188 @@ +/** + * @fileoverview Barrel file for all search models. + */ + +import {Column, Row} from './search_common'; +import { + Filter, + Fleet, + FleetColumnCatalogSection, + FleetColumnConfig, + FleetCountedValueList, + FleetFlatResults, + FleetFlatView, + FleetGroupExpandView, + FleetGroupHeaderView, + FleetGroupedResults, + FleetLandingConfig, + FleetPlainValueList, + FleetPromotedFilterKey, + FleetPromotedGroupByKey, + FleetResolvedFilterChip, + FleetResolvedGroupByChip, + FleetSuggestion, + SearchEntity, +} from './search_fleet'; +import { + ResolvedTjsChip, + TjsDefaultChip, + TjsEntity, + TjsFilter, + TjsPromotedKey, + TjsSuggestion, +} from './search_tjs'; + +// Re-export all model files under this barrel to maintain compatibility with existing code imports +export * from './search_common'; +export * from './search_fleet'; +export * from './search_tjs'; + +// ============================================================================= +// Requests and Responses +// ============================================================================= + +/** Fleet search config request. */ +export declare interface FleetSearchConfigRequest { + entity: SearchEntity; + fleet?: Fleet; +} + +/** Page-load configuration returned by GetFleetSearchConfig. */ +export declare interface FleetSearchConfig { + columns?: FleetColumnConfig; + landing?: FleetLandingConfig; +} + +/** Fleet search request. */ +export declare interface FleetSearchRequest { + entity: SearchEntity; + filters?: Filter[]; + fleet?: Fleet; + flat?: FleetFlatView; + groupHeader?: FleetGroupHeaderView; + groupExpand?: FleetGroupExpandView; +} + +/** Fleet search results. */ +export declare interface FleetSearchResults { + flat?: FleetFlatResults; + grouped?: FleetGroupedResults; +} + +/** Fleet suggestion request. */ +export declare interface FleetSuggestionRequest { + entity: SearchEntity; + input: string; + filters?: Filter[]; + groupBy?: string[]; + fleet?: Fleet; + limit?: number; +} + +/** Fleet search suggestion response. */ +export declare interface FleetSuggestionResponse { + items?: FleetSuggestion[]; +} + +/** Fleet chip resolver request. */ +export declare interface FleetChipResolverRequest { + filters?: Filter[]; + groupByKeys?: string[]; +} + +/** Fleet chip resolver response. */ +export declare interface FleetChipResolverResponse { + filterChips?: FleetResolvedFilterChip[]; + groupByChips?: FleetResolvedGroupByChip[]; +} + +/** Fleet value list request. */ +export declare interface FleetValueListRequest { + entity: SearchEntity; + key: string; + filters?: Filter[]; + fleet?: Fleet; +} + +/** Fleet value list response. */ +export declare interface FleetValueListResponse { + counted?: FleetCountedValueList; + plain?: FleetPlainValueList; +} + +/** Fleet promoted keys request. */ +export declare interface FleetPromotedKeysRequest { + entity: SearchEntity; + filters?: Filter[]; + groupBy?: string[]; + fleet?: Fleet; +} + +/** Fleet promoted keys response. */ +export declare interface FleetPromotedKeysResponse { + filterKeys?: FleetPromotedFilterKey[]; + groupByKeys?: FleetPromotedGroupByKey[]; +} + +/** Fleet column catalog request. */ +export declare interface FleetColumnCatalogRequest { + entity: SearchEntity; + fleet?: Fleet; + query?: string; + filters?: Filter[]; + recentKeys?: string[]; +} + +/** Fleet column catalog response. */ +export declare interface FleetColumnCatalogResponse { + sections?: FleetColumnCatalogSection[]; +} + +/** TJS search config request. */ +export declare interface TjsSearchConfigRequest { + entity: TjsEntity; +} + +/** TJS search config response. */ +export declare interface TjsSearchConfig { + entityLabel?: string; + defaultChips?: TjsDefaultChip[]; + promotedKeys?: TjsPromotedKey[]; +} + +/** TJS search request. */ +export declare interface TjsSearchRequest { + entity: TjsEntity; + filters?: TjsFilter[]; + pageToken?: string; +} + +/** TJS search response. */ +export declare interface TjsSearchResponse { + columns?: Column[]; + rows?: Row[]; + nextPageToken?: string; +} + +/** TJS search suggestion request. */ +export declare interface TjsSuggestionRequest { + entity: TjsEntity; + input: string; + filters?: TjsFilter[]; + limit?: number; +} + +/** TJS search suggestion response. */ +export declare interface TjsSuggestionResponse { + items?: TjsSuggestion[]; +} + +/** TJS resolve chips request. */ +export declare interface TjsResolveChipsRequest { + filters?: TjsFilter[]; +} + +/** TJS resolve chips response. */ +export declare interface TjsResolveChipsResponse { + chips?: ResolvedTjsChip[]; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_common.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_common.ts new file mode 100644 index 0000000000..ccfaa0e9b3 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_common.ts @@ -0,0 +1,121 @@ +/** + * @fileoverview Common data models 100% strictly matched with search_common.proto. + */ + +/** Structured text segment for rich suggestion rendering. */ +export declare interface TextSegment { + text: string; + emphasized?: boolean; +} + +/** Semantic indicator for status/result cells. + * OK → green (healthy/success: IDLE, PASS) + * ACTIVE → blue (in-progress: RUNNING, BUSY) + * ERROR → red (failure: FAILED, MISSING, ERROR) + * NEUTRAL → gray (no judgment: NEW, PREPPING, SUSPENDED) + */ +export enum Indicator { + INDICATOR_UNSPECIFIED = 'INDICATOR_UNSPECIFIED', + INDICATOR_OK = 'INDICATOR_OK', + INDICATOR_ACTIVE = 'INDICATOR_ACTIVE', + INDICATOR_ERROR = 'INDICATOR_ERROR', + INDICATOR_NEUTRAL = 'INDICATOR_NEUTRAL', +} + +/** Column header in search results. */ +export declare interface Column { + key: string; + displayName: string; +} + +/** A single row in a search results table. */ +export declare interface Row { + id: string; + cells?: Cell[]; +} + +/** A typed, self-describing table cell. */ +export declare interface Cell { + text?: TextCell; + link?: LinkCell; + status?: StatusCell; + chips?: ChipsCell; + multiLink?: MultiLinkCell; +} + +/** Plain text cell. */ +export declare interface TextCell { + value: string; +} + +/** A clickable text cell that navigates to another page. */ +export declare interface LinkCell { + text?: string; + target?: NavTarget; +} + +/** A status/result cell with a semantic indicator. */ +export declare interface StatusCell { + text: string; + indicator?: Indicator; +} + +/** Multi-value chip cell. */ +export declare interface ChipsCell { + values?: string[]; +} + +/** A multi-link cell: multiple clickable entries in one cell. */ +export declare interface MultiLinkCell { + entries?: LinkEntry[]; +} + +/** A single entry in a MultiLinkCell. */ +export declare interface LinkEntry { + text: string; + target?: NavTarget; +} + +/** Semantic navigation identity for a link. */ +export declare interface NavTarget { + device?: DeviceRef; + host?: HostRef; + test?: TestRef; + job?: JobRef; + session?: SessionRef; +} + +/** Navigation identity for a device. */ +export declare interface DeviceRef { + id: string; + hostName?: string; + hostIp?: string; +} + +/** Navigation identity for a host. */ +export declare interface HostRef { + hostName: string; + hostIp?: string; +} + +/** Navigation identity for a test. */ +export declare interface TestRef { + testId: string; + jobId?: string; +} + +/** Navigation identity for a job. */ +export declare interface JobRef { + jobId: string; +} + +/** Navigation identity for a session. */ +export declare interface SessionRef { + sessionId: string; +} + +/** Key identity descriptor. */ +export declare interface KeyDescriptor { + key: string; + displayName: string; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_fleet.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_fleet.ts new file mode 100644 index 0000000000..d58bf7cff1 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_fleet.ts @@ -0,0 +1,332 @@ +/** + * @fileoverview Inventory search data models extracted from search_fleet.proto + */ + +import {Column, KeyDescriptor, Row, TextSegment} from './search_common'; + +/** Fleet search entity type enum. */ +export enum SearchEntity { + SEARCH_ENTITY_UNSPECIFIED = 'SEARCH_ENTITY_UNSPECIFIED', + SEARCH_ENTITY_DEVICE = 'SEARCH_ENTITY_DEVICE', + SEARCH_ENTITY_HOST = 'SEARCH_ENTITY_HOST', +} + +/** Fleet scope choice enum. */ +export enum Fleet { + FLEET_UNSPECIFIED = 'FLEET_UNSPECIFIED', + FLEET_SELF = 'FLEET_SELF', + FLEET_ATS = 'FLEET_ATS', +} + +/** A single filter chip query contract for Fleet search. */ +export declare interface Filter { + key: string; + simple?: SimpleMatch; + complex?: ComplexMatch; +} + +/** Simple match condition. */ +export declare interface SimpleMatch { + values?: FilterValue[]; + negated?: boolean; +} + +/** Single value entry in a SimpleMatch. */ +export declare interface FilterValue { + value?: string; + noValue?: {}; +} + +/** Advanced complex match condition. */ +export declare interface ComplexMatch { + startsWith?: StartsWith; + containsSubstring?: ContainsSubstring; + matchesRegex?: MatchesRegex; + matchesExactly?: MatchesExactly; + matchesAtLeast?: MatchesAtLeast; +} + +/** Prefix match. */ +export declare interface StartsWith { + value: string; +} + +/** Substring match. */ +export declare interface ContainsSubstring { + value: string; + negated?: boolean; +} + +/** Regular expression match. */ +export declare interface MatchesRegex { + value: string; + negated?: boolean; +} + +/** Exact set match. */ +export declare interface MatchesExactly { + values?: string[]; +} + +/** Superset match. */ +export declare interface MatchesAtLeast { + values?: string[]; +} + +// ============================================================================= +// used for request and response +// ============================================================================= + +// ================GetFleetSearchConfig - FleetSearchConfig=========================== +/** Fleet column config. */ +export declare interface FleetColumnConfig { + recommended?: KeyDescriptor[]; + defaults?: FleetColumnDescriptor[]; +} + +/** Fleet column descriptor. */ +export declare interface FleetColumnDescriptor { + key: string; + displayName: string; + locked?: boolean; +} + +/** Fleet landing config. */ +export declare interface FleetLandingConfig { + enabled?: boolean; + browseAllCount?: number; + tryCategories?: FleetTryCategory[]; +} + +/** Fleet try category. */ +export declare interface FleetTryCategory { + label: string; + examples?: FleetTryExample[]; +} + +/** Fleet try example. */ +export declare interface FleetTryExample { + text: string; +} + +// ===========================SearchFleet=========================== +/** Flat view: paginated rows with columns and sort. */ +export declare interface FleetFlatView { + columns?: string[]; + sort?: FleetColumnSort; + page?: FleetPageRequest; +} + +/** Group-by header view: returns collapsed group cards. */ +export declare interface FleetGroupHeaderView { + groupBy?: string[]; + sort?: FleetGroupSort; + page?: FleetPageRequest; +} + +/** Expand one group: returns rows inside a specific group card. */ +export declare interface FleetGroupExpandView { + groupId: string; + columns?: string[]; + pageToken?: string; +} + +/** Sort by a column value. */ +export declare interface FleetColumnSort { + key: string; + ascending?: boolean; +} + +/** Cursor-based pagination for flat and group-header views. */ +export declare interface FleetPageRequest { + pageSize?: number; + pageToken?: string; +} + +/** Chip metadata for Fleet search. */ +export declare interface FleetFilterChipMetadata { + keyDisplayName?: string; + canUseAdvanced?: boolean; + isPlural?: boolean; +} + +/** Sort for group headers. */ +export declare interface FleetGroupSort { + field?: FleetGroupSortField; + ascending?: boolean; +} + +/** Sort field for group headers. */ +export declare interface FleetGroupSortField { + itemCount?: FleetItemCountSort; + groupKey?: string; +} + +/** Marker: sort groups by their item count. */ +export declare interface FleetItemCountSort {} + +/** Flat results: paginated rows with columns. */ +export declare interface FleetFlatResults { + columns?: Column[]; + rows?: Row[]; + total?: number; + rangeStart?: number; + rangeEnd?: number; + nextPageToken?: string; + prevPageToken?: string; +} + +/** Grouped results: group-by headers with counts. */ +export declare interface FleetGroupedResults { + groupByKeys?: Column[]; + groups?: FleetGroup[]; + totalGroups?: number; + totalItems?: number; + rangeStart?: number; + rangeEnd?: number; + nextPageToken?: string; + prevPageToken?: string; +} + +/** A single group in grouped results. */ +export declare interface FleetGroup { + groupId: string; + values?: string[]; + itemCount?: number; + utilization?: FleetUtilization; +} + +/** Device status breakdown within a group. */ +export declare interface FleetUtilization { + idle?: number; + busy?: number; + other?: number; + total?: number; +} + +// ===========================GetFleetSuggestions================ +/** Search bar suggestion item for Fleet search. */ +export declare interface FleetSuggestion { + label?: string; + mainText?: TextSegment[]; + count?: number; + countUnit?: string; + overMax?: boolean; + countPrefix?: string; + applyFilter?: FleetApplyFilter; + openPicker?: FleetOpenPicker; + addGroupBy?: FleetAddGroupBy; +} + +/** Fleet apply filter suggestion action. */ +export declare interface FleetApplyFilter { + resultingFilter?: Filter; + pillKey?: string; + pillCondition?: string; + metadata?: FleetFilterChipMetadata; +} + +/** Fleet open value picker suggestion action. */ +export declare interface FleetOpenPicker { + key: string; + metadata?: FleetFilterChipMetadata; + newChip?: FleetNewChip; + viewExisting?: FleetViewExisting; + stagedModify?: FleetStagedModification; +} + +/** Fleet add group-by suggestion action. */ +export declare interface FleetAddGroupBy { + key: string; + pillKey?: string; +} + +/** Suggestion Action detail sub-types */ +/** Detail shape for a new filter chip representation. */ +export declare interface FleetNewChip {} +/** Detail shape for viewing an existing filter chip configuration. */ +export declare interface FleetViewExisting {} +/** Detail shape for a staged modification. */ +export declare interface FleetStagedModification { + values?: string[]; +} + +// ===========================ResolveFleetChips response=========================== +/** Resolved display text for a filter chip. */ +export declare interface FleetResolvedFilterChip { + pillKey: string; + pillCondition: string; + metadata?: FleetFilterChipMetadata; +} + +/** Resolved display text for a group-by chip. */ +export declare interface FleetResolvedGroupByChip { + pillKey: string; + displayName: string; +} + +// ===============GetFleetValueList - FleetValueListResponse================= +/** Counted value list. */ +export declare interface FleetCountedValueList { + values?: FleetCountedValue[]; + noValueEntry?: FleetCountedNoValueEntry; +} + +/** Plain value list. */ +export declare interface FleetPlainValueList { + values?: FleetPlainValue[]; + noValueEntry?: FleetPlainNoValueEntry; +} + +/** Counted value. */ +export declare interface FleetCountedValue { + value: string; + displayLabel: string; + filtered?: number; + total?: number; +} + +/** Counted no value entry. */ +export declare interface FleetCountedNoValueEntry { + filtered?: number; + total?: number; +} + +/** Plain value. */ +export declare interface FleetPlainValue { + value: string; + displayLabel: string; +} + +/** Plain no-value entry. */ +export declare interface FleetPlainNoValueEntry {} + +// ============GetFleetPromotedKeys - FleetPromotedKeysResponse================ +/** Promoted filter key. */ +export declare interface FleetPromotedFilterKey { + key: string; + metadata?: FleetFilterChipMetadata; +} + +/** Promoted group-by key. */ +export declare interface FleetPromotedGroupByKey { + key: string; + displayName: string; + groupCount?: number; +} + +// ============GetFleetColumnCatalog - FleetColumnCatalogResponse=============== +/** Catalog section. */ +export declare interface FleetColumnCatalogSection { + heading: string; + entries?: FleetColumnCatalogEntry[]; + totalAvailable?: number; +} + +/** Column catalog entry. */ +export declare interface FleetColumnCatalogEntry { + key: string; + displayName: string; + deviceCount?: number; + reason?: string; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_tjs.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_tjs.ts new file mode 100644 index 0000000000..4eb667ff10 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/models/search_tjs.ts @@ -0,0 +1,122 @@ +/** + * @fileoverview Test/Job/Session models extracted from search_tjs.proto + */ + +import {TextSegment} from './search_common'; + +/** TJS search entity type enum. */ +export enum TjsEntity { + TJS_ENTITY_UNSPECIFIED = 'TJS_ENTITY_UNSPECIFIED', + TJS_ENTITY_TEST = 'TJS_ENTITY_TEST', + TJS_ENTITY_JOB = 'TJS_ENTITY_JOB', + TJS_ENTITY_SESSION = 'TJS_ENTITY_SESSION', +} + +/** TJS Filter contract. */ +export declare interface TjsFilter { + key: string; + stringValue?: {value: string}; + enumValues?: {values: string[]}; + timeRange?: TimeRange; + namedValue?: NamedValue; +} + +/** Time range filter for create_time. Both endpoints are RFC 3339 timestamp strings. */ +export declare interface TimeRange { + from?: string; + to?: string; +} + +/** Named key-value pair. */ +export declare interface NamedValue { + name: string; + value: string; +} + +// ============================================================================= +// used for request and response +// ============================================================================= + +// ================GetTjsSearchConfig - TjsSearchConfig=========================== +/** Default chip auto-applied at page load. */ +export declare interface TjsDefaultChip { + filter?: TjsFilter; + pillKey: string; + pillCondition: string; + keyDisplayName: string; +} + +/** Promoted key for TJS. */ +export declare interface TjsPromotedKey { + key: string; + displayName: string; + textInput?: TextInputPicker; + enumPicker?: EnumPicker; + timeRange?: TimeRangePicker; + namedPair?: NamedPairPicker; +} + +/** TextInputPicker */ +export declare interface TextInputPicker { + placeholder?: string; +} + +/** Enum option in EnumPicker. */ +export declare interface EnumOption { + value: string; + label: string; +} + +/** EnumPicker */ +export declare interface EnumPicker { + options?: EnumOption[]; + multiSelect?: boolean; +} + +/** TimeRangePicker */ +export declare interface TimeRangePicker {} + +/** NamedPairPicker */ +export declare interface NamedPairPicker { + namePlaceholder: string; + valuePlaceholder: string; +} + +// ================ GetTjsSuggestions - TjsSuggestionResponse + +/** Search bar suggestion item for TJS search. */ +export declare interface TjsSuggestion { + label?: string; + mainText?: TextSegment[]; + applyFilter?: ApplyTjsFilter; + openPicker?: OpenTjsPicker; +} + +/** TJS apply filter suggestion action. */ +export declare interface ApplyTjsFilter { + filter?: TjsFilter; + pillKey?: string; + pillCondition?: string; + keyDisplayName?: string; +} + +/** TJS open picker suggestion action. */ +export declare interface OpenTjsPicker { + key: string; + keyDisplayName?: string; + newChip?: TjsNewChip; + viewExisting?: TjsViewExisting; +} + +/** TJS suggestion picker type for a new chip. */ +export declare interface TjsNewChip {} +/** TJS suggestion picker type for viewing an existing chip. */ +export declare interface TjsViewExisting {} + +// ================ ResolveTjsChips - TjsResolveChipsResponse +/** Resolved TJS chip. */ +export declare interface ResolvedTjsChip { + pillKey: string; + pillCondition: string; + keyDisplayName: string; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/index.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/index.ts index 7a21f2180f..e4c84b3bd8 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/index.ts +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/index.ts @@ -7,6 +7,7 @@ export {MOCK_DEVICE_SCENARIOS} from './devices'; export {MOCK_HOST_SCENARIOS} from './hosts'; export {MOCK_JOB_SCENARIOS} from './jobs'; +export * from './search'; export {MOCK_SESSION_SCENARIOS} from './sessions'; export {MOCK_TEST_SCENARIOS} from './tests'; diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/fleet_scenarios.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/fleet_scenarios.ts new file mode 100644 index 0000000000..ef6591b125 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/fleet_scenarios.ts @@ -0,0 +1,441 @@ +import { + FleetColumnCatalogResponse, + FleetPromotedKeysResponse, + FleetSearchConfig, + FleetSearchResults, + FleetSuggestion, + FleetSuggestionResponse, + FleetValueListResponse, + Indicator, +} from '@deviceinfra/app/core/models/search'; + +/** Mock search configuration for lab fleet search. */ +export const MOCK_FLEET_SEARCH_CONFIG: FleetSearchConfig = { + columns: { + recommended: [ + {key: 'field::status', displayName: 'Status'}, + {key: 'dim::model', displayName: 'Model'}, + ], + defaults: [ + {key: 'id', displayName: 'Device ID / UUID', locked: true}, + {key: 'hostName', displayName: 'Host Name'}, + {key: 'status', displayName: 'Status'}, + {key: 'type', displayName: 'Device Type'}, + {key: 'owner', displayName: 'Owner'}, + {key: 'model', displayName: 'Model'}, + ], + }, + landing: { + enabled: true, + browseAllCount: 14, + tryCategories: [ + { + label: 'a value', + examples: [{text: 'pixel 7'}, {text: 'IDLE'}, {text: 'ANDROID'}], + }, + { + label: 'a key', + examples: [{text: 'Model'}, {text: 'Status'}, {text: 'Type'}], + }, + ], + }, +}; + +/** Mock search results for device entity type. */ +export const MOCK_FLEET_SEARCH_RESULTS_DEVICE: FleetSearchResults = { + flat: { + total: 3, + rangeStart: 1, + rangeEnd: 3, + rows: [ + { + id: 'device_12345', + cells: [ + { + link: { + text: 'device_12345', + target: {device: {id: 'device_12345'}}, + }, + }, + {text: {value: 'mh-lab-01.google.com'}}, + {status: {text: 'IDLE', indicator: Indicator.INDICATOR_OK}}, + {text: {value: 'ANDROID'}}, + {text: {value: 'qiupingf@google.com'}}, + {text: {value: 'Pixel 7'}}, + ], + }, + { + id: 'device_67890', + cells: [ + { + link: { + text: 'device_67890', + target: {device: {id: 'device_67890'}}, + }, + }, + {text: {value: 'mh-lab-02.google.com'}}, + {status: {text: 'BUSY', indicator: Indicator.INDICATOR_ACTIVE}}, + {text: {value: 'ANDROID'}}, + {text: {value: 'dev@google.com'}}, + {text: {value: 'Pixel 8'}}, + ], + }, + { + id: 'device_abcde', + cells: [ + { + link: { + text: 'device_abcde', + target: {device: {id: 'device_abcde'}}, + }, + }, + {text: {value: 'mh-lab-01.google.com'}}, + {status: {text: 'OFFLINE', indicator: Indicator.INDICATOR_ERROR}}, + {text: {value: 'IOS'}}, + {text: {value: 'qiupingf@google.com'}}, + {text: {value: 'iPhone 14'}}, + ], + }, + ], + }, +}; + +/** Mock search results for host entity type. */ +export const MOCK_FLEET_SEARCH_RESULTS_HOST: FleetSearchResults = { + flat: { + total: 2, + rangeStart: 1, + rangeEnd: 2, + rows: [ + { + id: 'mh-lab-01.google.com', + cells: [ + { + link: { + text: 'mh-lab-01.google.com', + target: {host: {hostName: 'mh-lab-01.google.com'}}, + }, + }, + {text: {value: 'mh-lab-01.google.com'}}, + {status: {text: 'HEALTHY', indicator: Indicator.INDICATOR_OK}}, + {text: {value: 'LINUX'}}, + {text: {value: 'qiupingf@google.com'}}, + {text: {value: 'Standard'}}, + ], + }, + { + id: 'mh-lab-02.google.com', + cells: [ + { + link: { + text: 'mh-lab-02.google.com', + target: {host: {hostName: 'mh-lab-02.google.com'}}, + }, + }, + {text: {value: 'mh-lab-02.google.com'}}, + {status: {text: 'BUSY', indicator: Indicator.INDICATOR_ACTIVE}}, + {text: {value: 'LINUX'}}, + {text: {value: 'dev@google.com'}}, + {text: {value: 'High Memory'}}, + ], + }, + ], + }, +}; + +/** Mock default suggestions for device search. */ +export const MOCK_FLEET_SUGGESTIONS_DEVICE_DEFAULT: FleetSuggestion[] = [ + { + label: 'Add filter', + mainText: [ + {text: 'Status is ', emphasized: false}, + {text: 'IDLE', emphasized: true}, + ], + count: 95584, + applyFilter: { + pillKey: 'Status', + pillCondition: 'IDLE', + resultingFilter: { + key: 'field::status', + simple: {values: [{value: 'IDLE'}]}, + }, + }, + }, + { + label: 'Add filter', + mainText: [ + {text: 'Model is ', emphasized: false}, + {text: 'Pixel 7', emphasized: true}, + ], + count: 5, + applyFilter: { + pillKey: 'Model', + pillCondition: 'Pixel 7', + resultingFilter: { + key: 'dim::model', + simple: {values: [{value: 'Pixel 7'}]}, + }, + }, + }, + { + openPicker: { + key: 'field::type', + metadata: {keyDisplayName: 'Type', canUseAdvanced: true, isPlural: true}, + }, + }, + { + openPicker: { + key: 'field::owners', + metadata: {keyDisplayName: 'Owner', canUseAdvanced: true, isPlural: true}, + }, + }, + { + openPicker: { + key: 'dim::pool', + metadata: {keyDisplayName: 'Pool', canUseAdvanced: true, isPlural: false}, + }, + }, + { + openPicker: { + key: 'host::host_name', + metadata: { + keyDisplayName: 'Host Name', + canUseAdvanced: true, + isPlural: false, + }, + }, + }, + { + openPicker: { + key: 'field::quarantined', + metadata: { + keyDisplayName: 'Quarantine', + canUseAdvanced: false, + isPlural: false, + }, + }, + }, + { + openPicker: { + key: 'dim::os', + metadata: {keyDisplayName: 'OS', canUseAdvanced: true, isPlural: false}, + }, + }, +]; + +/** Mock suggestion response for device search. */ +export const MOCK_FLEET_SUGGESTION_RESPONSE_DEVICE: FleetSuggestionResponse = { + items: MOCK_FLEET_SUGGESTIONS_DEVICE_DEFAULT, +}; + +/** Mock suggestion items for host search. */ +export const MOCK_FLEET_SUGGESTIONS_HOST_DEFAULT: FleetSuggestion[] = [ + { + label: 'Add filter', + mainText: [ + {text: 'Status is ', emphasized: false}, + {text: 'RUNNING', emphasized: true}, + ], + count: 15, + applyFilter: { + pillKey: 'Status', + pillCondition: 'RUNNING', + }, + }, + { + label: 'Add filter', + mainText: [ + {text: 'Host contains ', emphasized: false}, + {text: 'host', emphasized: true}, + ], + count: 3, + applyFilter: { + pillKey: 'Host Name', + pillCondition: 'host', + }, + }, +]; + +/** Mock suggestion response for host search. */ +export const MOCK_FLEET_SUGGESTION_RESPONSE_HOST: FleetSuggestionResponse = { + items: MOCK_FLEET_SUGGESTIONS_HOST_DEFAULT, +}; + +/** Mock promoted filter and group keys for fleet. */ +export const MOCK_FLEET_PROMOTED_KEYS: FleetPromotedKeysResponse = { + filterKeys: [ + { + key: 'field::status', + metadata: { + keyDisplayName: 'Status', + canUseAdvanced: true, + isPlural: false, + }, + }, + { + key: 'dim::model', + metadata: { + keyDisplayName: 'Model', + canUseAdvanced: true, + isPlural: false, + }, + }, + { + key: 'field::type', + metadata: {keyDisplayName: 'Type', canUseAdvanced: true, isPlural: true}, + }, + { + key: 'field::owners', + metadata: { + keyDisplayName: 'Owner', + canUseAdvanced: true, + isPlural: true, + }, + }, + { + key: 'dim::pool', + metadata: { + keyDisplayName: 'Pool', + canUseAdvanced: true, + isPlural: false, + }, + }, + { + key: 'host::host_name', + metadata: { + keyDisplayName: 'Host Name', + canUseAdvanced: true, + isPlural: false, + }, + }, + { + key: 'field::quarantined', + metadata: { + keyDisplayName: 'Quarantine', + canUseAdvanced: false, + isPlural: false, + }, + }, + { + key: 'dim::os', + metadata: { + keyDisplayName: 'OS', + canUseAdvanced: true, + isPlural: false, + }, + }, + ], + groupByKeys: [ + {key: 'status', displayName: 'Status', groupCount: 4}, + {key: 'model', displayName: 'Model', groupCount: 15}, + {key: 'type', displayName: 'Type', groupCount: 3}, + {key: 'pool', displayName: 'Pool', groupCount: 8}, + ], +}; + +/** Mock column catalog response. */ +export const MOCK_FLEET_COLUMN_CATALOG: FleetColumnCatalogResponse = { + sections: [ + { + heading: 'Suggested for you', + entries: [ + {key: 'status', displayName: 'Status', deviceCount: 14}, + {key: 'model', displayName: 'Model', deviceCount: 14}, + ], + }, + ], +}; + +/** Mock status values checklist selector. */ +export const MOCK_FLEET_VALUE_LIST_STATUS: FleetValueListResponse = { + counted: { + values: [ + {value: 'IDLE', displayLabel: 'IDLE', filtered: 8, total: 10}, + {value: 'BUSY', displayLabel: 'BUSY', filtered: 5, total: 6}, + {value: 'OFFLINE', displayLabel: 'OFFLINE', filtered: 1, total: 2}, + {value: 'INIT', displayLabel: 'INIT', filtered: 0, total: 1}, + ], + }, +}; + +/** Mock model values checklist selector. */ +export const MOCK_FLEET_VALUE_LIST_MODEL: FleetValueListResponse = { + counted: { + values: [ + {value: 'Pixel 7', displayLabel: 'Pixel 7', filtered: 6, total: 8}, + {value: 'Pixel 8', displayLabel: 'Pixel 8', filtered: 4, total: 5}, + {value: 'Pixel 6', displayLabel: 'Pixel 6', filtered: 2, total: 3}, + { + value: 'Galaxy S23', + displayLabel: 'Galaxy S23', + filtered: 1, + total: 2, + }, + ], + noValueEntry: {filtered: 1, total: 2}, + }, +}; + +/** Mock type values checklist selector. */ +export const MOCK_FLEET_VALUE_LIST_TYPE: FleetValueListResponse = { + counted: { + values: [ + { + value: 'ANDROID', + displayLabel: 'ANDROID', + filtered: 12, + total: 15, + }, + {value: 'IOS', displayLabel: 'IOS', filtered: 2, total: 3}, + { + value: 'EMULATOR', + displayLabel: 'EMULATOR', + filtered: 1, + total: 1, + }, + ], + }, +}; + +/** Mock owner values checklist selector. */ +export const MOCK_FLEET_VALUE_LIST_OWNER: FleetValueListResponse = { + counted: { + values: [ + { + value: 'qiupingf@google.com', + displayLabel: 'qiupingf@google.com', + filtered: 10, + total: 12, + }, + { + value: 'dev@google.com', + displayLabel: 'dev@google.com', + filtered: 4, + total: 5, + }, + ], + noValueEntry: {filtered: 2, total: 3}, + }, +}; + +/** Mock fallback counted values checklist selector. */ +export const MOCK_FLEET_VALUE_LIST_DEFAULT: FleetValueListResponse = { + counted: { + values: [ + {value: 'Option A', displayLabel: 'Option A', filtered: 5, total: 8}, + {value: 'Option B', displayLabel: 'Option B', filtered: 3, total: 4}, + ], + noValueEntry: {filtered: 1, total: 1}, + }, +}; + +/** Mock plain text values suggestion/selector list. */ +export const MOCK_FLEET_VALUE_LIST_PLAIN: FleetValueListResponse = { + plain: { + values: [ + {value: 'device_uuid_001', displayLabel: 'device_uuid_001'}, + {value: 'device_uuid_002', displayLabel: 'device_uuid_002'}, + {value: 'device_uuid_003', displayLabel: 'device_uuid_003'}, + ], + }, +}; diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/index.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/index.ts new file mode 100644 index 0000000000..98f18c8cf8 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/index.ts @@ -0,0 +1,2 @@ +export * from './fleet_scenarios'; +export * from './tjs_scenarios'; diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/tjs_scenarios.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/tjs_scenarios.ts new file mode 100644 index 0000000000..d197e72f86 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/services/mock_data/search/tjs_scenarios.ts @@ -0,0 +1,502 @@ +import { + Indicator, + TjsResolveChipsResponse, + TjsSearchConfig, + TjsSearchResponse, + TjsSuggestion, + TjsSuggestionResponse, +} from '../../../../core/models/search'; + +/** Mock page-load configuration for test search page. */ +export const MOCK_TJS_SEARCH_CONFIG_TEST: TjsSearchConfig = { + entityLabel: 'Tests', + defaultChips: [ + { + pillKey: 'Result', + pillCondition: 'PASS', + keyDisplayName: 'Result', + filter: { + key: 'result', + enumValues: {values: ['PASS']}, + }, + }, + ], + promotedKeys: [ + { + key: 'test_id', + displayName: 'Test ID', + textInput: {placeholder: 'Full test id'}, + }, + { + key: 'job_id', + displayName: 'Job ID', + textInput: {placeholder: 'Full job id'}, + }, + { + key: 'name', + displayName: 'Name', + textInput: {placeholder: 'Test name contains…'}, + }, + { + key: 'user', + displayName: 'User', + textInput: {placeholder: 'Exact username'}, + }, + { + key: 'device_id', + displayName: 'Device ID', + textInput: {placeholder: 'Exact device id'}, + }, + { + key: 'host_name', + displayName: 'Host name', + textInput: {placeholder: 'Host name contains…'}, + }, + { + key: 'status', + displayName: 'Status', + enumPicker: { + options: [ + {value: 'NEW', label: 'NEW'}, + {value: 'ASSIGNED', label: 'ASSIGNED'}, + {value: 'RUNNING', label: 'RUNNING'}, + {value: 'DONE', label: 'DONE'}, + {value: 'SUSPENDED', label: 'SUSPENDED'}, + ], + multiSelect: true, + }, + }, + { + key: 'result', + displayName: 'Result', + enumPicker: { + options: [ + {value: 'PASS', label: 'PASS'}, + {value: 'FAIL', label: 'FAIL'}, + {value: 'ERROR', label: 'ERROR'}, + {value: 'TIMEOUT', label: 'TIMEOUT'}, + {value: 'ABORT', label: 'ABORT'}, + {value: 'SKIP', label: 'SKIP'}, + {value: 'UNKNOWN', label: 'UNKNOWN'}, + ], + multiSelect: true, + }, + }, + { + key: 'property', + displayName: 'Actual user', + textInput: {placeholder: 'Exact actual user'}, + }, + { + key: 'property', + displayName: 'Property', + namedPair: { + namePlaceholder: 'Property name', + valuePlaceholder: 'Property value', + }, + }, + { + key: 'create_time', + displayName: 'Create Time', + timeRange: {}, + }, + ], +}; + +/** Mock page-load configuration for job search page. */ +export const MOCK_TJS_SEARCH_CONFIG_JOB: TjsSearchConfig = { + entityLabel: 'Jobs', + defaultChips: [ + { + pillKey: 'Status', + pillCondition: 'RUNNING', + keyDisplayName: 'Status', + filter: { + key: 'status', + enumValues: {values: ['RUNNING']}, + }, + }, + ], + promotedKeys: [ + { + key: 'id', + displayName: 'Job ID', + textInput: {placeholder: 'Full job id'}, + }, + { + key: 'name', + displayName: 'Name', + textInput: {placeholder: 'Job name contains…'}, + }, + { + key: 'user', + displayName: 'User', + textInput: {placeholder: 'Exact username'}, + }, + { + key: 'device', + displayName: 'Device', + textInput: {placeholder: 'Device type'}, + }, + { + key: 'driver', + displayName: 'Driver', + textInput: {placeholder: 'Driver'}, + }, + { + key: 'decorator', + displayName: 'Decorator', + textInput: {placeholder: 'Decorator'}, + }, + { + key: 'status', + displayName: 'Status', + enumPicker: { + options: [ + {value: 'NEW', label: 'NEW'}, + {value: 'ASSIGNED', label: 'ASSIGNED'}, + {value: 'RUNNING', label: 'RUNNING'}, + {value: 'DONE', label: 'DONE'}, + {value: 'SUSPENDED', label: 'SUSPENDED'}, + ], + multiSelect: true, + }, + }, + { + key: 'result', + displayName: 'Result', + enumPicker: { + options: [ + {value: 'PASS', label: 'PASS'}, + {value: 'FAIL', label: 'FAIL'}, + {value: 'ERROR', label: 'ERROR'}, + {value: 'TIMEOUT', label: 'TIMEOUT'}, + {value: 'ABORT', label: 'ABORT'}, + {value: 'SKIP', label: 'SKIP'}, + {value: 'UNKNOWN', label: 'UNKNOWN'}, + ], + multiSelect: true, + }, + }, + { + key: 'dimension', + displayName: 'Dimension', + namedPair: { + namePlaceholder: 'Dimension name', + valuePlaceholder: 'Value', + }, + }, + { + key: 'property', + displayName: 'Actual user', + textInput: {placeholder: 'Exact actual user'}, + }, + { + key: 'property', + displayName: 'Property', + namedPair: { + namePlaceholder: 'Property name', + valuePlaceholder: 'Property value', + }, + }, + { + key: 'create_time', + displayName: 'Create Time', + timeRange: {}, + }, + ], +}; + +/** Mock page-load configuration for session search page. */ +export const MOCK_TJS_SEARCH_CONFIG_SESSION: TjsSearchConfig = { + entityLabel: 'Sessions', + defaultChips: [ + { + pillKey: 'User', + pillCondition: 'qiupingf', + keyDisplayName: 'User', + filter: { + key: 'user', + stringValue: {value: 'qiupingf'}, + }, + }, + ], + promotedKeys: [ + { + key: 'id', + displayName: 'Session ID', + textInput: {placeholder: 'Full session id'}, + }, + { + key: 'user', + displayName: 'User', + textInput: {placeholder: 'Exact username'}, + }, + { + key: 'client', + displayName: 'Client', + textInput: {placeholder: 'Exact client'}, + }, + { + key: 'label', + displayName: 'Label', + textInput: {placeholder: 'Exact label'}, + }, + ], +}; + +/** Mock test search response (100% compliant with search_common.proto). */ +export const MOCK_TJS_SEARCH_RESPONSE_TEST: TjsSearchResponse = { + columns: [ + {key: 'test_id', displayName: 'Test ID'}, + {key: 'name', displayName: 'Test name'}, + {key: 'user', displayName: 'User'}, + {key: 'actual_user', displayName: 'Actual user'}, + {key: 'status', displayName: 'Status'}, + {key: 'result', displayName: 'Result'}, + {key: 'start_time', displayName: 'Start time'}, + {key: 'duration', displayName: 'Duration'}, + {key: 'host_name', displayName: 'Lab (host)'}, + {key: 'devices', displayName: 'Devices'}, + ], + rows: [ + { + id: 'baf01a94-f625-4d65-9f3d-938d30e5f6f8', + cells: [ + { + link: { + text: 'baf01a94-f625-4d65-9f3d-938d30e5f6f8', + target: { + test: { + testId: 'baf01a94-f625-4d65-9f3d-938d30e5f6f8', + jobId: 'j_987654', + }, + }, + }, + }, + { + text: { + value: + 'com.google.codelab.mobileharness.android.hellomobileharness.HelloMobileHarnessTest#addStatsToSponge', + }, + }, + {text: {value: 'qiupingf'}}, + {text: {value: 'qiupingf'}}, + {status: {text: 'DONE', indicator: Indicator.INDICATOR_OK}}, + {status: {text: 'PASS', indicator: Indicator.INDICATOR_OK}}, + {text: {value: '1785826199655'}}, + {text: {value: '32358'}}, + {text: {value: 'mt08-dm02.atl14.mtaas.google.com'}}, + { + multiLink: { + entries: [ + { + text: '18261FDF6003KC', + target: {device: {id: '18261FDF6003KC'}}, + }, + { + text: '98888FDF6005AB', + target: {device: {id: '98888FDF6005AB'}}, + }, + ], + }, + }, + ], + }, + { + id: 'b6a4e952-5a5c-4728-875f-322bfce27bc8', + cells: [ + { + link: { + text: 'b6a4e952-5a5c-4728-875f-322bfce27bc8', + target: { + test: { + testId: 'b6a4e952-5a5c-4728-875f-322bfce27bc8', + jobId: 'j_987654', + }, + }, + }, + }, + { + text: { + value: + 'com.google.codelab.mobileharness.android.hellomobileharness.HelloMobileHarnessTest#plusOneButton', + }, + }, + {text: {value: 'qiupingf'}}, + {text: {value: 'qiupingf'}}, + {status: {text: 'DONE', indicator: Indicator.INDICATOR_OK}}, + {status: {text: 'PASS', indicator: Indicator.INDICATOR_OK}}, + {text: {value: '1785826199666'}}, + {text: {value: '49537'}}, + {text: {value: 'mt208-dm01.atl14.mtaas.google.com'}}, + {chips: {values: ['18271FDF600EZ0']}}, + ], + }, + ], +}; + +/** Mock job search response (100% compliant with search_common.proto). */ +export const MOCK_TJS_SEARCH_RESPONSE_JOB: TjsSearchResponse = { + columns: [ + {key: 'job_id', displayName: 'Job ID'}, + {key: 'name', displayName: 'Job name'}, + {key: 'user', displayName: 'User'}, + {key: 'status', displayName: 'Status'}, + {key: 'start_time', displayName: 'Start time'}, + {key: 'duration', displayName: 'Duration'}, + ], + rows: [ + { + id: 'job_1', + cells: [ + {link: {text: 'j_987654', target: {job: {jobId: 'j_987654'}}}}, + {text: {value: 'MobileHarness Core Tests'}}, + {text: {value: 'qiupingf'}}, + {status: {text: 'RUNNING', indicator: Indicator.INDICATOR_ACTIVE}}, + {text: {value: '1723550400000'}}, + {text: {value: '124500'}}, + ], + }, + { + id: 'job_2', + cells: [ + {link: {text: 'j_876543', target: {job: {jobId: 'j_876543'}}}}, + {text: {value: 'Device Infra Integration'}}, + {text: {value: 'dev_user'}}, + {status: {text: 'DONE', indicator: Indicator.INDICATOR_OK}}, + {text: {value: '1723546800000'}}, + {text: {value: '600000'}}, + ], + }, + ], +}; + +/** Mock session search response (100% compliant with search_common.proto). */ +export const MOCK_TJS_SEARCH_RESPONSE_SESSION: TjsSearchResponse = { + columns: [ + {key: 'session_id', displayName: 'Session ID'}, + {key: 'user', displayName: 'User'}, + {key: 'status', displayName: 'Status'}, + {key: 'start_time', displayName: 'Start time'}, + {key: 'duration', displayName: 'Duration'}, + ], + rows: [ + { + id: 'session_1', + cells: [ + {link: {text: 's_abcdef', target: {session: {sessionId: 's_abcdef'}}}}, + {text: {value: 'qiupingf'}}, + {status: {text: 'DONE', indicator: Indicator.INDICATOR_OK}}, + {text: {value: '1723550400000'}}, + {text: {value: '180000'}}, + ], + }, + { + id: 'session_2', + cells: [ + {link: {text: 's_123456', target: {session: {sessionId: 's_123456'}}}}, + {text: {value: 'dev_user'}}, + {status: {text: 'RUNNING', indicator: Indicator.INDICATOR_ACTIVE}}, + {text: {value: '1723554000000'}}, + {text: {value: '90000'}}, + ], + }, + ], +}; + +/** Mock default suggestions for test search. */ +export const MOCK_TJS_SUGGESTIONS_TEST_DEFAULT: TjsSuggestion[] = [ + { + label: 'Add filter', + mainText: [ + {text: 'Result is ', emphasized: false}, + {text: 'PASS', emphasized: true}, + ], + applyFilter: { + pillKey: 'Result', + pillCondition: 'PASS', + keyDisplayName: 'Result', + filter: { + key: 'result', + enumValues: {values: ['PASS']}, + }, + }, + }, +]; + +/** Mock suggestions list for job search. */ +export const MOCK_TJS_SUGGESTIONS_JOB: TjsSuggestion[] = [ + { + label: 'Add filter', + mainText: [ + {text: 'Status is ', emphasized: false}, + {text: 'RUNNING', emphasized: true}, + ], + applyFilter: { + pillKey: 'Status', + pillCondition: 'RUNNING', + keyDisplayName: 'Status', + filter: { + key: 'status', + enumValues: {values: ['RUNNING']}, + }, + }, + }, +]; + +/** Mock suggestions list for session search. */ +export const MOCK_TJS_SUGGESTIONS_SESSION: TjsSuggestion[] = [ + { + label: 'Add filter', + mainText: [ + {text: 'User is ', emphasized: false}, + {text: 'qiupingf', emphasized: true}, + ], + applyFilter: { + pillKey: 'User', + pillCondition: 'qiupingf', + keyDisplayName: 'User', + filter: { + key: 'user', + stringValue: {value: 'qiupingf'}, + }, + }, + }, +]; + +/** Mock suggestion response for test search. */ +export const MOCK_TJS_SUGGESTION_RESPONSE_TEST: TjsSuggestionResponse = { + items: MOCK_TJS_SUGGESTIONS_TEST_DEFAULT, +}; + +/** Mock suggestion response for job search. */ +export const MOCK_TJS_SUGGESTION_RESPONSE_JOB: TjsSuggestionResponse = { + items: MOCK_TJS_SUGGESTIONS_JOB, +}; + +/** Mock suggestion response for session search. */ +export const MOCK_TJS_SUGGESTION_RESPONSE_SESSION: TjsSuggestionResponse = { + items: MOCK_TJS_SUGGESTIONS_SESSION, +}; + +/** Mock resolve chips response. */ +export const MOCK_TJS_RESOLVE_CHIPS_RESPONSE: TjsResolveChipsResponse = { + chips: [ + { + pillKey: 'Result', + pillCondition: 'PASS', + keyDisplayName: 'Result', + }, + { + pillKey: 'Status', + pillCondition: 'RUNNING', + keyDisplayName: 'Status', + }, + { + pillKey: 'User', + pillCondition: 'qiupingf', + keyDisplayName: 'User', + }, + ], +}; diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/fake_search_service.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/fake_search_service.ts new file mode 100644 index 0000000000..a7be0ed674 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/fake_search_service.ts @@ -0,0 +1,309 @@ +import {Injectable} from '@angular/core'; +import {Observable, of} from 'rxjs'; +import { + FleetChipResolverRequest, + FleetChipResolverResponse, + FleetColumnCatalogRequest, + FleetColumnCatalogResponse, + FleetPromotedKeysRequest, + FleetPromotedKeysResponse, + FleetSearchConfig, + FleetSearchConfigRequest, + FleetSearchRequest, + FleetSearchResults, + FleetSuggestion, + FleetSuggestionRequest, + FleetSuggestionResponse, + FleetValueListRequest, + FleetValueListResponse, + SearchEntity, + TjsEntity, + TjsResolveChipsRequest, + TjsResolveChipsResponse, + TjsSearchConfig, + TjsSearchConfigRequest, + TjsSearchRequest, + TjsSearchResponse, + TjsSuggestionRequest, + TjsSuggestionResponse, +} from '../../models/search'; +import { + MOCK_FLEET_COLUMN_CATALOG, + MOCK_FLEET_PROMOTED_KEYS, + MOCK_FLEET_SEARCH_CONFIG, + MOCK_FLEET_SEARCH_RESULTS_DEVICE, + MOCK_FLEET_SEARCH_RESULTS_HOST, + MOCK_FLEET_VALUE_LIST_DEFAULT, + MOCK_FLEET_VALUE_LIST_MODEL, + MOCK_FLEET_VALUE_LIST_OWNER, + MOCK_FLEET_VALUE_LIST_PLAIN, + MOCK_FLEET_VALUE_LIST_STATUS, + MOCK_FLEET_VALUE_LIST_TYPE, + MOCK_TJS_SEARCH_CONFIG_JOB, + MOCK_TJS_SEARCH_CONFIG_SESSION, + MOCK_TJS_SEARCH_CONFIG_TEST, + MOCK_TJS_SEARCH_RESPONSE_JOB, + MOCK_TJS_SEARCH_RESPONSE_SESSION, + MOCK_TJS_SEARCH_RESPONSE_TEST, + MOCK_TJS_SUGGESTION_RESPONSE_JOB, + MOCK_TJS_SUGGESTION_RESPONSE_SESSION, + MOCK_TJS_SUGGESTION_RESPONSE_TEST, +} from '../mock_data'; +import {SearchService} from './search_service'; + +/** Mock implementation of SearchService for frontend testing & offline fallback. */ +@Injectable() +export class FakeSearchService extends SearchService { + constructor() { + super(); + } + + override getFleetSearchConfig( + request: FleetSearchConfigRequest, + ): Observable { + return of(MOCK_FLEET_SEARCH_CONFIG); + } + + override searchFleet( + request: FleetSearchRequest, + ): Observable { + if (request.entity === SearchEntity.SEARCH_ENTITY_DEVICE) { + return of(MOCK_FLEET_SEARCH_RESULTS_DEVICE); + } + if (request.entity === SearchEntity.SEARCH_ENTITY_HOST) { + return of(MOCK_FLEET_SEARCH_RESULTS_HOST); + } + return of({ + flat: { + total: 0, + rangeStart: 0, + rangeEnd: 0, + rows: [], + }, + }); + } + + override getFleetSuggestions( + request: FleetSuggestionRequest, + ): Observable { + const input = (request.input || '').trim().toLowerCase(); + const activeFilters = request.filters || []; + const activeKeys = new Set(activeFilters.map((f) => f.key.toLowerCase())); + + if (!input) { + const keys = [ + {key: 'field::status', name: 'Status', isPlural: false}, + {key: 'dim::model', name: 'Model', isPlural: false}, + {key: 'field::type', name: 'Type', isPlural: true}, + {key: 'field::owners', name: 'Owner', isPlural: true}, + {key: 'dim::pool', name: 'Pool', isPlural: false}, + {key: 'host::host_name', name: 'Host Name', isPlural: false}, + {key: 'field::quarantined', name: 'Quarantine', isPlural: false}, + {key: 'dim::os', name: 'OS', isPlural: false}, + ]; + + const items: FleetSuggestion[] = keys.map((k) => { + const isActive = activeKeys.has(k.key.toLowerCase()); + return { + label: isActive ? `Modify ${k.name}` : 'Add filter', + mainText: [{text: k.name, emphasized: false}], + openPicker: { + key: k.key, + metadata: { + keyDisplayName: k.name, + canUseAdvanced: true, + isPlural: k.isPlural, + }, + }, + }; + }); + + return of({items}); + } + + const items: FleetSuggestion[] = []; + const knownValues = [ + {key: 'dim::model', name: 'Model', val: 'Pixel 8', isPlural: false}, + {key: 'dim::model', name: 'Model', val: 'Pixel 7', isPlural: false}, + {key: 'field::status', name: 'Status', val: 'IDLE', isPlural: false}, + {key: 'field::status', name: 'Status', val: 'BUSY', isPlural: false}, + {key: 'field::status', name: 'Status', val: 'OFFLINE', isPlural: false}, + {key: 'field::type', name: 'Type', val: 'ANDROID', isPlural: true}, + {key: 'field::type', name: 'Type', val: 'IOS', isPlural: true}, + ]; + + for (const kv of knownValues) { + if ( + kv.val.toLowerCase().includes(input) || + kv.name.toLowerCase().includes(input) + ) { + const isActive = activeKeys.has(kv.key.toLowerCase()); + items.push({ + label: isActive ? `Modify ${kv.name}` : 'Add filter', + mainText: [ + { + text: `${kv.name} ${kv.isPlural ? 'are' : 'is'} `, + emphasized: false, + }, + {text: kv.val, emphasized: true}, + ], + count: 10, + applyFilter: { + pillKey: kv.name, + pillCondition: kv.val, + resultingFilter: { + key: kv.key, + simple: {values: [{value: kv.val}]}, + }, + metadata: { + keyDisplayName: kv.name, + canUseAdvanced: true, + isPlural: kv.isPlural, + }, + }, + }); + } + } + + if (items.length === 0) { + items.push({ + label: 'Add filter', + mainText: [ + {text: 'Search ', emphasized: false}, + {text: request.input, emphasized: true}, + ], + applyFilter: { + pillKey: 'Search', + pillCondition: request.input, + resultingFilter: { + key: 'search_query', + simple: {values: [{value: request.input}]}, + }, + }, + }); + } + + return of({items}); + } + + override resolveFleetChips( + request: FleetChipResolverRequest, + ): Observable { + return of({ + filterChips: (request.filters || []).map((f) => ({ + pillKey: f.key, + pillCondition: f.simple?.values?.[0]?.value || '', + })), + groupByChips: (request.groupByKeys || []).map((k) => ({ + pillKey: k, + displayName: k, + })), + }); + } + + override getFleetValueList( + request: FleetValueListRequest, + ): Observable { + const k = (request.key || '').toLowerCase(); + if ( + k.includes('uuid') || + k.includes('device_id') || + k.includes('host_name') + ) { + return of(MOCK_FLEET_VALUE_LIST_PLAIN); + } + if (k.includes('status')) { + return of(MOCK_FLEET_VALUE_LIST_STATUS); + } + if (k.includes('model')) { + return of(MOCK_FLEET_VALUE_LIST_MODEL); + } + if (k.includes('type')) { + return of(MOCK_FLEET_VALUE_LIST_TYPE); + } + if (k.includes('owner')) { + return of(MOCK_FLEET_VALUE_LIST_OWNER); + } + return of(MOCK_FLEET_VALUE_LIST_DEFAULT); + } + + override getFleetPromotedKeys( + request: FleetPromotedKeysRequest, + ): Observable { + return of(MOCK_FLEET_PROMOTED_KEYS); + } + + override getFleetColumnCatalog( + request: FleetColumnCatalogRequest, + ): Observable { + return of(MOCK_FLEET_COLUMN_CATALOG); + } + + override getTjsSearchConfig( + request: TjsSearchConfigRequest, + ): Observable { + const entity = request.entity; + if (entity === TjsEntity.TJS_ENTITY_TEST) { + return of(MOCK_TJS_SEARCH_CONFIG_TEST); + } + if (entity === TjsEntity.TJS_ENTITY_JOB) { + return of(MOCK_TJS_SEARCH_CONFIG_JOB); + } + return of(MOCK_TJS_SEARCH_CONFIG_SESSION); + } + + override searchTjs(request: TjsSearchRequest): Observable { + const entity = request.entity; + if (entity === TjsEntity.TJS_ENTITY_TEST) { + return of(MOCK_TJS_SEARCH_RESPONSE_TEST); + } + if (entity === TjsEntity.TJS_ENTITY_JOB) { + return of(MOCK_TJS_SEARCH_RESPONSE_JOB); + } + return of(MOCK_TJS_SEARCH_RESPONSE_SESSION); + } + + override getTjsSuggestions( + request: TjsSuggestionRequest, + ): Observable { + const entity = request.entity; + if (entity === TjsEntity.TJS_ENTITY_TEST) { + return of(MOCK_TJS_SUGGESTION_RESPONSE_TEST); + } + if (entity === TjsEntity.TJS_ENTITY_JOB) { + return of(MOCK_TJS_SUGGESTION_RESPONSE_JOB); + } + return of(MOCK_TJS_SUGGESTION_RESPONSE_SESSION); + } + + override resolveTjsChips( + request: TjsResolveChipsRequest, + ): Observable { + const filters = request.filters || []; + const resolvedChips = filters.map((f) => { + const key = f.key; + let pillKey = key.charAt(0).toUpperCase() + key.slice(1); + if (key === 'create_time') { + pillKey = 'Create Time'; + } + + let condition = ''; + if (f.stringValue) { + condition = f.stringValue.value; + } else if (f.enumValues && f.enumValues.values) { + condition = f.enumValues.values.join(', '); + } else if (f.namedValue) { + condition = `${f.namedValue.name}:${f.namedValue.value}`; + } else if (f.timeRange) { + condition = `${f.timeRange.from || ''} ~ ${f.timeRange.to || ''}`.trim(); + } + + return { + pillKey, + pillCondition: condition, + keyDisplayName: pillKey, + }; + }); + return of({chips: resolvedChips}); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/http_search_service.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/http_search_service.ts new file mode 100644 index 0000000000..42997d5fba --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/http_search_service.ts @@ -0,0 +1,131 @@ +import {HttpClient} from '@angular/common/http'; +import {inject, Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {APP_DATA, AppData} from '../../models/app_data'; +import { + FleetChipResolverRequest, + FleetChipResolverResponse, + FleetColumnCatalogRequest, + FleetColumnCatalogResponse, + FleetPromotedKeysRequest, + FleetPromotedKeysResponse, + FleetSearchConfig, + FleetSearchConfigRequest, + FleetSearchRequest, + FleetSearchResults, + FleetSuggestionRequest, + FleetSuggestionResponse, + FleetValueListRequest, + FleetValueListResponse, + TjsResolveChipsRequest, + TjsResolveChipsResponse, + TjsSearchConfig, + TjsSearchConfigRequest, + TjsSearchRequest, + TjsSearchResponse, + TjsSuggestionRequest, + TjsSuggestionResponse, +} from '../../models/search'; +import {SearchService} from './search_service'; + +/** An implementation of the SearchService that uses HTTP to fetch data. */ +@Injectable() +export class HttpSearchService extends SearchService { + private readonly appData: AppData = inject(APP_DATA); + private readonly fleetApiUrl = `${this.appData.labConsoleServerUrl}/v6/fleet-search`; + private readonly tjsApiUrl = `${this.appData.labConsoleServerUrl}/v6/tjs-search`; + private readonly http = inject(HttpClient); + + constructor() { + super(); + } + + override getFleetSearchConfig( + request: FleetSearchConfigRequest, + ): Observable { + return this.http.get(`${this.fleetApiUrl}/config`, { + params: request as unknown as Record, + }); + } + + override searchFleet( + request: FleetSearchRequest, + ): Observable { + return this.http.post(this.fleetApiUrl, request); + } + + override getFleetSuggestions( + request: FleetSuggestionRequest, + ): Observable { + return this.http.post( + `${this.fleetApiUrl}/suggestions`, + request, + ); + } + + override resolveFleetChips( + request: FleetChipResolverRequest, + ): Observable { + return this.http.post( + `${this.fleetApiUrl}/resolve-chips`, + request, + ); + } + + override getFleetValueList( + request: FleetValueListRequest, + ): Observable { + return this.http.post( + `${this.fleetApiUrl}/value-list`, + request, + ); + } + + override getFleetPromotedKeys( + request: FleetPromotedKeysRequest, + ): Observable { + return this.http.post( + `${this.fleetApiUrl}/promoted-keys`, + request, + ); + } + + override getFleetColumnCatalog( + request: FleetColumnCatalogRequest, + ): Observable { + return this.http.post( + `${this.fleetApiUrl}/column-catalog`, + request, + ); + } + + override getTjsSearchConfig( + request: TjsSearchConfigRequest, + ): Observable { + return this.http.get(`${this.tjsApiUrl}/config`, { + params: request as unknown as Record, + }); + } + + override searchTjs(request: TjsSearchRequest): Observable { + return this.http.post(this.tjsApiUrl, request); + } + + override getTjsSuggestions( + request: TjsSuggestionRequest, + ): Observable { + return this.http.post( + `${this.tjsApiUrl}/suggestions`, + request, + ); + } + + override resolveTjsChips( + request: TjsResolveChipsRequest, + ): Observable { + return this.http.post( + `${this.tjsApiUrl}/resolve-chips`, + request, + ); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/search_service.ts b/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/search_service.ts new file mode 100644 index 0000000000..253afe2cec --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/core/services/search/search_service.ts @@ -0,0 +1,80 @@ +import {InjectionToken} from '@angular/core'; +import {Observable} from 'rxjs'; +import { + FleetChipResolverRequest, + FleetChipResolverResponse, + FleetColumnCatalogRequest, + FleetColumnCatalogResponse, + FleetPromotedKeysRequest, + FleetPromotedKeysResponse, + FleetSearchConfig, + FleetSearchConfigRequest, + FleetSearchRequest, + FleetSearchResults, + FleetSuggestionRequest, + FleetSuggestionResponse, + FleetValueListRequest, + FleetValueListResponse, + TjsResolveChipsRequest, + TjsResolveChipsResponse, + TjsSearchConfig, + TjsSearchConfigRequest, + TjsSearchRequest, + TjsSearchResponse, + TjsSuggestionRequest, + TjsSuggestionResponse, +} from '../../models/search'; + +/** Injection token for referencing SearchService implementations. */ +export const SEARCH_SERVICE = new InjectionToken( + 'SearchService', +); + +/** + * Abstract class defining the contract for FE v6 SearchService operations. + */ +export abstract class SearchService { + // --- Fleet (device/host) Search RPCs --- + abstract getFleetSearchConfig( + request: FleetSearchConfigRequest, + ): Observable; + + abstract searchFleet( + request: FleetSearchRequest, + ): Observable; + + abstract getFleetSuggestions( + request: FleetSuggestionRequest, + ): Observable; + + abstract resolveFleetChips( + request: FleetChipResolverRequest, + ): Observable; + + abstract getFleetValueList( + request: FleetValueListRequest, + ): Observable; + + abstract getFleetPromotedKeys( + request: FleetPromotedKeysRequest, + ): Observable; + + abstract getFleetColumnCatalog( + request: FleetColumnCatalogRequest, + ): Observable; + + // --- TJS (test/job/session) Search RPCs --- + abstract getTjsSearchConfig( + request: TjsSearchConfigRequest, + ): Observable; + + abstract searchTjs(request: TjsSearchRequest): Observable; + + abstract getTjsSuggestions( + request: TjsSuggestionRequest, + ): Observable; + + abstract resolveTjsChips( + request: TjsResolveChipsRequest, + ): Observable; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/host_detail/components/host_overview/host_overview.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/host_detail/components/host_overview/host_overview.ng.html index ba48f6ffa5..b0f94e972f 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/features/host_detail/components/host_overview/host_overview.ng.html +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/host_detail/components/host_overview/host_overview.ng.html @@ -284,30 +284,11 @@

TYPE - @if(element.types && element.types.length > 0) { -
-
- {{getFirstType(element.types)}} -
- @if(element.types.length > 1) { - - } -
- } @else { - — - } +
@@ -464,23 +445,11 @@

TYPE - - - -
- {{type.type}} -
-
+ title="Device Types" + [subtitle]="sub.id" + >
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/host_detail/components/host_overview/host_overview.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/host_detail/components/host_overview/host_overview.ts index b185da0051..483559143d 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/features/host_detail/components/host_overview/host_overview.ts +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/host_detail/components/host_overview/host_overview.ts @@ -73,7 +73,7 @@ import { MasterDetailLayout, NavItem, } from '../../../../shared/components/master_detail_layout/master_detail_layout'; -import {OverflowList} from '../../../../shared/components/overflow_list/overflow_list'; +import {OverflowChipListComponent} from '../../../../shared/components/overflow_chip_list/overflow_chip_list'; import { SearchableListOverlayComponent, SearchableListOverlayData, @@ -196,7 +196,7 @@ const STATUS_SEMANTIC_MAP: Record = InfoCard, NavLink, SearchableListOverlayComponent, - OverflowList, + OverflowChipListComponent, ActionButton, TooltipIfTruncatedDirective, ], diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.ng.html new file mode 100644 index 0000000000..321ef8ab95 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.ng.html @@ -0,0 +1,59 @@ +
+ + +
+ @for (m of advModesList; track m.id) { + + } +
+ +
+ @if (!isMultiMode()) { + + } @else { +
+ + +
+
+ @for (val of values(); track val; let i = $index) { + + {{ val }} + + close + + + } +
+ } +
+
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.scss new file mode 100644 index 0000000000..2233edaf23 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.scss @@ -0,0 +1,172 @@ +.vp-advanced { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1 1 auto; + overflow-y: auto; + padding: 8px 16px 12px; +} + +.vp-adv-back { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + padding: 4px 8px 4px 4px; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + color: #0b57d0; + + &:hover { + background: rgba(11, 87, 208, 0.08); + } + + .material-symbols-outlined { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + } +} + +.vp-adv-modes { + display: flex; + flex-direction: column; +} + +.vp-adv-mode { + display: flex; + align-items: center; + gap: 10px; + height: 36px; + padding: 0 8px; + border-radius: 8px; + cursor: pointer; + font-size: 0.8125rem; + color: #1f1f1f; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + .vp-adv-radio { + font-size: 20px; + color: #444746; + } + + &.active .vp-adv-radio { + color: #0b57d0; + } +} + +.vp-adv-input { + display: flex; + flex-direction: column; + gap: 8px; +} + +.vp-adv-field { + width: 100%; + box-sizing: border-box; + height: 36px; + padding: 0 12px; + font-size: 0.8125rem; + font-family: inherit; + color: #1f1f1f; + background: #fff; + border: 1px solid #c4c7c5; + border-radius: 8px; + outline: none; + + &:hover { + border-color: #444746; + } + + &:focus { + border-color: #0b57d0; + } +} + +.vp-adv-input-row { + display: flex; + align-items: center; + gap: 8px; + + .vp-adv-field { + flex: 1; + min-width: 0; + } +} + +.vp-adv-add { + flex-shrink: 0; + height: 36px; + padding: 0 16px; + border-radius: 8px; + border: 1px solid #c4c7c5; + cursor: pointer; + background: #fff; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 600; + color: #0b57d0; + + &:hover { + background: rgba(11, 87, 208, 0.08); + } +} + +.vp-adv-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.vp-adv-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + height: 28px; + padding: 0 4px 0 12px; + border-radius: 8px; + background: #d7e3f8; + color: #041e49; + font-size: 0.8125rem; +} + +.vp-adv-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + line-height: 1; + display: inline-flex; + align-items: center; +} + +.vp-adv-chip-x { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 50%; + flex-shrink: 0; + color: #041e49; + + &:hover { + background: rgba(0, 0, 0, 0.12); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.ts new file mode 100644 index 0000000000..03776f715a --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/advanced_match_view/advanced_match_view.ts @@ -0,0 +1,67 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + model, + output, + signal, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatIconModule} from '@angular/material/icon'; + +import { + ADV_MODES_LIST, + AdvancedMatchMode, +} from '../../../models/value_picker_models'; + +/** Standalone sub-view handling prefix, regex, substring, and multi-chip matching modes. */ +@Component({ + selector: 'app-advanced-match-view', + standalone: true, + templateUrl: './advanced_match_view.ng.html', + styleUrl: './advanced_match_view.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FormsModule, MatIconModule], +}) +export class AdvancedMatchView { + readonly mode = model.required(); + readonly text = model.required(); + readonly values = model.required(); + + readonly backToSimple = output(); + readonly apply = output(); + + readonly inputVal = signal(''); + readonly advModesList = ADV_MODES_LIST; + + readonly isMultiMode = computed(() => { + const m = this.mode(); + return m === 'exactly' || m === 'at_least'; + }); + + readonly placeholder = computed(() => { + const currentMode = this.mode(); + const found = this.advModesList.find( + (m: {id: AdvancedMatchMode; label: string; placeholder: string}) => + m.id === currentMode, + ); + return found?.placeholder || 'Enter value…'; + }); + + addChip() { + const val = this.inputVal().trim(); + if (!val) return; + const current = this.values(); + if (!current.some((v) => v.toLowerCase() === val.toLowerCase())) { + this.values.set([...current, val]); + } + this.inputVal.set(''); + } + + removeChip(index: number) { + const next = [...this.values()]; + next.splice(index, 1); + this.values.set(next); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.ng.html new file mode 100644 index 0000000000..5bd396c0e5 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.ng.html @@ -0,0 +1,344 @@ +@if (store.pickerAnchor()) { + +
+ +
+ {{ store.pickerTitle() || store.pickerKey() }} + @if (showNegateToggle()) { +
+ + @if (showPolarityMenu()) { +
+
+ + check + + {{ posVerb() }} +
+
+ + check + + {{ negVerb() }} +
+
+ } +
+ } + + @if (showAdvancedMenu()) { +
+ + @if (showOverflowMenu()) { +
+
+ tune + Advanced matching +
+
+ } +
+ } +
+ + + @if (isAdvanced()) { + + } @else { + @switch (config()?.type) { + @case ('range') { + +
+
+ From: + +
+
+ To: + +
+
+ } + @case ('namedPair') { + + @if (config()?.needsName) { + + } + + } + @case ('text') { + + + } + @default { + + @if (showSearchInput()) { + + } + + +
+ + + Value + @if (sortBy() === 'value') { + + {{ sortAsc() ? 'arrow_upward' : 'arrow_downward' }} + + } + + + @if (!isPlain()) { + + Filtered + @if (sortBy() === 'filtered') { + + {{ sortAsc() ? 'arrow_upward' : 'arrow_downward' }} + + } + + + Total + @if (sortBy() === 'total') { + + {{ sortAsc() ? 'arrow_upward' : 'arrow_downward' }} + + } + + } +
+ + +
+ @if (state().loading && showAddRow()) { +
+ + check_box_outline_blank + + + Add "{{ searchQuery().trim() }}" + +
+ } + + @if (selectedSet().size > 0) { +
Clear all
+ } + + @if (state().loading && selectedSet().size > 0) { + @for (val of selectedSet(); track val) { +
+ + check_box + + + {{ val }} + +
+ } + } + + @if (state().loading) { +
Loading values…
+ } + + + +
+ + {{ selectedSet().has(item.value) ? 'check_box' : 'check_box_outline_blank' }} + + + @if (item.isNoValue) { + (no value) + } @else { + {{ item.displayLabel }} + } + + @if (showRowActions() && !item.disabled) { + + + + + } + @if (!isPlain()) { + + {{ item.filtered !== undefined && item.filtered !== null ? (item.filtered | number) : '' }} + + + {{ item.total !== undefined && item.total !== null ? (item.total | number) : '' }} + + } +
+
+ + @if (pinnedValues().length > 0) { + @for (item of pinnedValues(); track item.value) { + + } +
+ } + + @if (!state().loading && displayList().length === 0) { +
No matching values
+ } @else { + @for (item of displayList(); track item.value) { + + } + } +
+ } + } + } + + + +
+
+} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.scss new file mode 100644 index 0000000000..41b40b9ec5 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.scss @@ -0,0 +1,615 @@ +@keyframes vpOpen { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes vp-pulse { + 0%, 100% { + opacity: 0.4; + } + 50% { + opacity: 0.85; + } +} + +.value-picker { + position: fixed; + z-index: 400; + width: 420px; + max-height: 460px; + display: flex; + flex-direction: column; + background: #fff; + border-radius: 12px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); + overflow: hidden; + border: 1px solid #c4c7c5; + transform-origin: top left; + animation: vpOpen 0.15s ease-out; + + .vp-header { + padding: 12px 16px 8px; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + position: relative; + } + + .vp-title { + font-size: 0.875rem; + font-weight: 600; + color: #1f1f1f; + } + + .vp-polarity-wrap { + position: relative; + } + + .vp-polarity-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + cursor: pointer; + padding: 4px 6px 4px 10px; + border-radius: 8px; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1; + border: 1px solid #c4c7c5; + background: transparent; + color: #1f1f1f; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + .material-symbols-outlined { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + color: #444746; + } + + &.negate { + color: #8c1d18; + .material-symbols-outlined { + color: #8c1d18; + } + } + } + + .vp-polarity-menu { + position: absolute; + z-index: 500; + min-width: 120px; + top: 100%; + left: 0; + margin-top: 2px; + background: #fff; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1); + padding: 4px 0; + display: none; + white-space: nowrap; + + &.open { + display: block; + } + + .vp-pm-item { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 16px; + cursor: pointer; + font-size: 0.875rem; + color: #1f1f1f; + white-space: nowrap; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + .vp-pm-check { + font-size: 18px; + color: #0b57d0; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + visibility: hidden; + flex-shrink: 0; + + &.visible { + visibility: visible; + } + } + } + } + + .vp-overflow-wrap { + position: relative; + margin-left: auto; + } + + .vp-overflow-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: transparent; + cursor: pointer; + color: #444746; + transition: background 0.15s; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + .material-symbols-outlined { + font-size: 20px; + } + } + + .vp-overflow-menu { + position: absolute; + z-index: 500; + min-width: 160px; + top: 100%; + right: 0; + margin-top: 2px; + background: #fff; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1); + padding: 4px 0; + display: none; + white-space: nowrap; + + &.open { + display: block; + } + + .vp-om-item { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 16px; + cursor: pointer; + font-size: 0.875rem; + color: #1f1f1f; + white-space: nowrap; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + .material-symbols-outlined { + font-size: 18px; + color: #444746; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + } + } + } + + .vp-search { + margin: 0 12px 4px; + flex-shrink: 0; + + input { + width: 100%; + box-sizing: border-box; + height: 34px; + padding: 0 10px; + border: 1px solid #c4c7c5; + border-radius: 8px; + font-size: 0.8125rem; + outline: none; + background: #fff; + + &:focus { + border-color: #0b57d0; + } + } + } + + .vp-col-header { + display: flex; + align-items: center; + padding: 0 16px; + height: 28px; + flex-shrink: 0; + border-bottom: 1px solid #c4c7c5; + font-size: 0.6875rem; + color: #444746; + font-weight: 500; + user-select: none; + + .vp-col { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + line-height: 1; + + span { + display: inline-flex; + align-items: center; + line-height: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + .material-symbols-outlined { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &.active { + color: #0b57d0; + } + } + + .vp-col-spacer { + width: 26px; + flex-shrink: 0; + } + + .vp-col-grow { + flex: 1; + } + + .vp-cnt-col { + width: 60px; + text-align: right; + flex-shrink: 0; + } + } + + .vp-list { + overflow-y: auto; + flex: 1 1 auto; + min-height: 40px; + max-height: 260px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #c4c7c5; + border-radius: 3px; + + &:hover { + background: #a8aaa8; + } + } + } + + .vp-clear-all { + padding: 6px 16px; + font-size: 0.75rem; + color: #0b57d0; + cursor: pointer; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } + + .vp-pinned-divider { + height: 1px; + background: #c4c7c5; + margin: 4px 16px; + } + + .vp-item { + display: flex; + align-items: center; + height: 36px; + padding: 0 16px; + cursor: pointer; + gap: 8px; + position: relative; + white-space: nowrap; + + &:hover { + background: rgba(0, 0, 0, 0.04); + + .vp-actions { + display: inline-flex; + } + } + + &.dimmed { + opacity: 0.4; + } + + &.vp-add-row { + font-weight: 500; + color: #0b57d0; + + .vp-val { + color: #0b57d0; + + strong { + font-weight: 600; + } + } + + &:hover { + background: rgba(11, 87, 208, 0.08); + } + } + + &.disabled { + opacity: 0.55; + cursor: not-allowed; + + &:hover { + background: transparent; + } + + .vp-check.disabled { + color: #747775; + } + + .disabled-text { + color: #747775; + } + } + } + + .vp-check { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 1; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: #0b57d0; + } + + .vp-val { + flex: 1; + font-size: 0.8125rem; + color: #1f1f1f; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + display: inline-flex; + align-items: center; + + &.no-value-text, + em { + color: #444746; + font-style: italic; + white-space: nowrap; + display: inline; + } + } + + &.id-key { + .vp-cnt, + .vp-cnt-col { + display: none; + } + + .vp-actions { + right: 16px; + } + } + + .vp-actions { + display: none; + align-items: center; + gap: 2px; + position: absolute; + right: 136px; + top: 0; + bottom: 0; + margin: auto 0; + height: 24px; + background: linear-gradient(90deg, transparent 0%, #fff 20%); + padding-left: 16px; + padding-right: 4px; + } + + .vp-act-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + padding: 0 6px; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 0.6875rem; + font-weight: 500; + line-height: 1; + background: transparent; + color: #0b57d0; + white-space: nowrap; + + &:hover { + background: rgba(11, 87, 208, 0.08); + } + + .material-symbols-outlined { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + } + } + + .vp-cnt { + width: 60px; + text-align: right; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + flex-shrink: 0; + + &.filtered { + color: #0b57d0; + } + + &.total { + color: #444746; + } + } + + .vp-empty { + padding: 16px; + text-align: center; + font-size: 0.8125rem; + color: #444746; + } + + .vp-loading-hint { + padding: 12px 16px; + text-align: center; + font-size: 0.8125rem; + font-style: italic; + color: #444746; + animation: vp-pulse 1.2s ease-in-out infinite; + } + + .vp-footer { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid #c4c7c5; + flex-shrink: 0; + } + + .vp-selected { + font-size: 0.75rem; + color: #444746; + white-space: nowrap; + } + + .vp-grow { + flex: 1; + } + + .vp-btn { + height: 34px; + padding: 0 16px; + border-radius: 8px; + border: none; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease, box-shadow 0.15s ease; + + &.vp-cancel { + background: transparent; + color: #0b57d0; + + &:hover { + background: rgba(11, 87, 208, 0.08); + } + } + + &.vp-apply { + background: #0b57d0; + color: #fff; + + &:hover { + background: #0a4ec0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + + &:disabled { + opacity: 0.4; + cursor: default; + box-shadow: none; + } + } + } + + .vp-daterange { + padding: 0 12px 12px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .vp-dt-row { + display: flex; + align-items: center; + gap: 8px; + } + + .vp-dt-op { + width: 44px; + flex-shrink: 0; + text-align: left; + line-height: 36px; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + color: #444746; + } + + .vp-daterange input[type="datetime-local"] { + flex: 1; + min-width: 0; + height: 36px; + box-sizing: border-box; + padding: 0 12px; + font-size: 0.8125rem; + font-family: inherit; + color: #1f1f1f; + background: #fff; + border: 1px solid #c4c7c5; + border-radius: 8px; + + &:focus { + outline: none; + border-color: #0b57d0; + } + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.ts new file mode 100644 index 0000000000..87c721398c --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/filter_value_picker/filter_value_picker.ts @@ -0,0 +1,330 @@ +import { + ConnectionPositionPair, + OverlayModule, +} from '@angular/cdk/overlay'; +import {CommonModule} from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + linkedSignal, + signal, + viewChild, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; + +import { + AdvancedMatchMode, + PickerValueItem, +} from '../../models/value_picker_models'; +import {SearchPageStore} from '../../services/search_page_store'; +import {AdvancedMatchView} from './advanced_match_view/advanced_match_view'; +import { + buildValuePickerApplyEvent, + computeFilteredAndSortedValues, + computePinnedValues, + parseDateRange, +} from '../../utils'; + +/** + * Unified facade value picker component with embedded CDK Connected Overlay. + * Manages local draft state for filtering, sorting, selecting, and advanced match modes. + */ +@Component({ + selector: 'app-filter-value-picker', + standalone: true, + templateUrl: './filter_value_picker.ng.html', + styleUrl: './filter_value_picker.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + FormsModule, + OverlayModule, + MatButtonModule, + MatIconModule, + AdvancedMatchView, + ], +}) +export class FilterValuePicker { + /** Global search page store reference injected via Angular dependency injection. */ + readonly store = inject(SearchPageStore); + + /** Active value picker configuration signal from store. */ + readonly config = this.store.pickerConfig; + /** Effective picker state (candidate values, selected items, loading status) from store. */ + readonly state = this.store.effectivePickerState; + + /** Reference to the search input element inside the picker popover. */ + readonly searchInput = viewChild>('searchInput'); + + /** Angular CDK Overlay positioning strategies (bottom-start preferred, top-start fallback). */ + readonly pickerOverlayPositions: ConnectionPositionPair[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 6, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -6, + }, + ]; + + // ===================== Local Draft Reactive State ===================== + /** Set of selected value strings linked to store effective state. */ + readonly selectedSet = linkedSignal(() => new Set(this.state().selectedValues)); + /** Set of manually staged custom free-text inputs added by user. */ + readonly stagedCustomInputs = linkedSignal({ + source: () => this.state().selectedValues, + computation: () => new Set(), + }); + /** Polarity flag (true = IS NOT / exclude, false = IS / include). */ + readonly isNegated = linkedSignal(() => !!this.state().negated); + /** Flag indicating whether advanced match view is active. */ + readonly isAdvanced = linkedSignal(() => !!this.state().advanced?.active); + + /** Advanced match mode ('prefix', 'suffix', 'contains', 'regex', etc.). */ + readonly advMode = linkedSignal( + () => this.state().advanced?.mode || 'prefix', + ); + /** Advanced mode text query. */ + readonly advText = linkedSignal(() => this.state().advanced?.text || ''); + /** Advanced mode array of value items. */ + readonly advValues = linkedSignal(() => this.state().advanced?.values || []); + + /** Parsed date range state object for date-range picker type. */ + readonly rangeState = linkedSignal(() => + parseDateRange(this.state().selectedValues), + ); + /** 'From' timestamp string for date-range picker type. */ + readonly rangeFrom = linkedSignal(() => this.rangeState().from); + /** 'To' timestamp string for date-range picker type. */ + readonly rangeTo = linkedSignal(() => this.rangeState().to); + + /** Property key name for namedPair picker type. */ + readonly propName = linkedSignal(() => { + const vals = Array.from(this.state().selectedValues); + if (this.config()?.needsName && vals.length >= 2) return vals[0] || ''; + return this.config()?.title || ''; + }); + /** Property value string for namedPair picker type. */ + readonly propVal = linkedSignal(() => { + const vals = Array.from(this.state().selectedValues); + if (this.config()?.needsName && vals.length >= 2) return vals[1] || ''; + return vals.join(', '); + }); + /** Text value string for plain text picker type. */ + readonly textVal = linkedSignal(() => + Array.from(this.state().selectedValues).join(', '), + ); + + /** Local search query entered in popover filter input. */ + readonly searchQuery = signal(''); + /** Active column to sort candidate items by ('value' | 'filtered' | 'total'). */ + readonly sortBy = signal<'value' | 'filtered' | 'total'>('filtered'); + /** Sort direction flag (true = ascending, false = descending). */ + readonly sortAsc = signal(false); + /** Polarity menu open state flag. */ + readonly showPolarityMenu = signal(false); + /** More/Overflow options menu open state flag. */ + readonly showOverflowMenu = signal(false); + + // ===================== Computed Derived Signals ===================== + /** Whether the target filter key represents plural items. */ + readonly isPlural = computed(() => !!this.config()?.isPlural); + /** Positive verb text ('is' or 'are'). */ + readonly posVerb = computed(() => (this.isPlural() ? 'are' : 'is')); + /** Negative verb text ('is not' or 'are not'). */ + readonly negVerb = computed(() => (this.isPlural() ? 'are not' : 'is not')); + + /** Whether the candidate items are plain values without count columns. */ + readonly isPlain = computed(() => { + if (this.config()?.valuesType === 'plain') return true; + const list = this.state().values || []; + return ( + list.length > 0 && + list.every((v) => v.filtered === undefined && v.total === undefined) + ); + }); + + /** Whether polarity negate toggle button is visible. */ + readonly showNegateToggle = computed( + () => !!this.config()?.showNegateToggle && !this.isAdvanced(), + ); + /** Whether advanced match options menu button is visible. */ + readonly showAdvancedMenu = computed( + () => !!this.config()?.showAdvancedMenu && !this.isAdvanced(), + ); + /** Whether main filter search input box is visible. */ + readonly showSearchInput = computed( + () => this.config()?.showSearchInput !== false, + ); + /** Whether row actions ('only', 'copy') are enabled. */ + readonly showRowActions = computed(() => !!this.config()?.showRowActions); + + /** Filtered and sorted candidate value items for list view. */ + readonly displayList = computed(() => + computeFilteredAndSortedValues({ + items: this.state().values || [], + query: this.searchQuery(), + sortBy: this.sortBy(), + sortAsc: this.sortAsc(), + stagedCustomInputs: this.stagedCustomInputs(), + isLoading: !!this.state().loading, + }), + ); + + /** Pinned selected items pinned to top when list exceeds threshold. */ + readonly pinnedValues = computed(() => + computePinnedValues( + this.state().values || [], + this.selectedSet(), + this.searchQuery(), + !!this.state().loading, + ), + ); + + /** Whether "Add custom input" row should be displayed while loading. */ + readonly showAddRow = computed(() => { + if (!this.state().loading) return false; + const q = this.searchQuery().trim().toLowerCase(); + if (!q) return false; + + const inSelected = Array.from(this.selectedSet()).some( + (s) => s.toLowerCase() === q, + ); + if (inSelected) return false; + + const inValues = (this.state().values || []).some( + (v) => v.value.toLowerCase() === q || v.displayLabel.toLowerCase() === q, + ); + return !inValues; + }); + + /** Footer status summary text indicating selected/excluded count or advanced values count. */ + readonly footerStatusText = computed(() => { + if (this.isAdvanced()) { + const isMulti = this.advMode() === 'exactly' || this.advMode() === 'at_least'; + if (!isMulti) { + return this.advText().trim() ? '1 value added' : 'No values added'; + } + const count = this.advValues().length; + return count === 0 ? 'No values added' : `${count} value${count > 1 ? 's' : ''} added`; + } + + const type = this.config()?.type; + if (type !== 'list' && type !== undefined) { + return ''; + } + + const count = this.selectedSet().size; + const verb = this.isNegated() ? 'excluded' : 'selected'; + return count === 0 ? `No options ${verb}` : `${count} option${count > 1 ? 's' : ''} ${verb}`; + }); + + constructor() { + afterNextRender(() => { + this.searchInput()?.nativeElement.focus(); + }); + } + + // ===================== User Interaction Handlers ===================== + /** Toggles sort direction or changes the active sorting column. */ + toggleSort(col: 'value' | 'filtered' | 'total') { + if (this.sortBy() === col) { + this.sortAsc.set(!this.sortAsc()); + return; + } + this.sortBy.set(col); + this.sortAsc.set(col === 'value'); + } + + /** Toggles selection state of a candidate value item. */ + toggleValue(item: PickerValueItem) { + if (item.disabled) return; + const next = new Set(this.selectedSet()); + if (next.has(item.value)) { + next.delete(item.value); + } else { + next.add(item.value); + } + this.selectedSet.set(next); + } + + /** Deselects all items and selects only the specified value item. */ + selectOnlyValue(val: string) { + this.selectedSet.set(new Set([val])); + } + + /** Copies string value to system clipboard. */ + copyValue(val: string) { + if (!val) return; + navigator.clipboard.writeText(val); + } + + /** Clears all selected value items in local draft state. */ + clearAll() { + this.selectedSet.set(new Set()); + } + + /** Stages a custom free-text input value entered by user. */ + addCustomInput(raw: string) { + const q = raw.trim(); + if (!q) return; + + const nextSel = new Set(this.selectedSet()); + nextSel.add(q); + this.selectedSet.set(nextSel); + + const nextStaged = new Set(this.stagedCustomInputs()); + nextStaged.add(q); + this.stagedCustomInputs.set(nextStaged); + this.searchQuery.set(''); + } + + /** Handles Enter keypress inside popover search input box. */ + onSearchEnter() { + const q = this.searchQuery().trim(); + if (q) { + this.addCustomInput(q); + return; + } + this.onApply(); + } + + /** Dispatches ValuePickerApplyEvent to store and closes popover overlay. */ + onApply() { + const event = buildValuePickerApplyEvent({ + type: this.config()?.type, + isAdvanced: this.isAdvanced(), + negated: this.isNegated(), + selectedSet: this.selectedSet(), + searchQuery: this.searchQuery(), + advMode: this.advMode(), + advText: this.advText(), + advValues: this.advValues(), + rangeFrom: this.rangeFrom(), + rangeTo: this.rangeTo(), + propName: this.propName(), + propVal: this.propVal(), + textVal: this.textVal(), + }); + this.store.applyValuePicker(event); + this.store.closeValuePicker(); + } + + /** Closes popover overlay without applying changes. */ + onCancel() { + this.store.closeValuePicker(); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.ng.html new file mode 100644 index 0000000000..f5d03cdc3f --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.ng.html @@ -0,0 +1,43 @@ +@if (store.isPromotedKeysLoading() && store.promotedFilterKeys().length === 0 && store.promotedGroupByKeys().length === 0) { + +} @else if (store.promotedFilterKeys().length > 0 || store.promotedGroupByKeys().length > 0) { +
+ @if (store.promotedFilterKeys().length > 0) { +
+ Filter by: + @for (k of store.promotedFilterKeys(); track (k.key + (k.metadata?.keyDisplayName || ''))) { + + } +
+ } + + @if (store.promotedGroupByKeys().length > 0) { +
+ Group by: + @for (k of store.promotedGroupByKeys(); track k.key) { + + } +
+ } +
+} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.scss new file mode 100644 index 0000000000..916f984b13 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.scss @@ -0,0 +1,98 @@ +:host { + display: block; + width: 100%; +} + +.preset-filters-container { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 960px; + width: 100%; + min-height: 40px; + box-sizing: border-box; + animation: fadeIn 0.2s cubic-bezier(0, 0, 0.2, 1); + + &.loading-placeholder { + pointer-events: none; + } +} + +.preset-shimmer-placeholder { + display: flex; + align-items: center; + height: 24px; +} + +.preset-shimmer-line { + width: 280px; + max-width: 60%; + height: 14px; + background: linear-gradient(90deg, #f0f4f9 0%, #e8eaed 50%, #f0f4f9 100%); + background-size: 200% 100%; + border-radius: 7px; + animation: shimmer-line 1.8s ease-in-out infinite; +} + +@keyframes shimmer-line { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.preset-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + font-size: 0.8125rem; + min-height: 24px; +} + +.preset-label { + font-size: 0.8125rem; + font-weight: 500; + color: #444746; + min-width: 64px; +} + +.preset-link { + border: none; + background: none; + padding: 0; + cursor: pointer; + font-family: inherit; + font-size: 0.8125rem; + color: #0b57d0; + text-decoration: none; + transition: color 0.15s; + + &:hover { + text-decoration: underline; + } + + &.active { + font-weight: 600; + color: #041e49; + text-decoration: underline; + } +} + +.preset-count { + font-size: 0.75rem; + opacity: 0.75; + margin-left: 4px; + font-variant-numeric: tabular-nums; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.ts new file mode 100644 index 0000000000..ae3b21b6ab --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/filter_presets/filter_presets.ts @@ -0,0 +1,49 @@ +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; + +import {SearchPageStore} from '../../../services/search_page_store'; +import {PromotedFilterKeyItem, PromotedGroupByKeyItem} from '../../../utils'; + +/** + * Standalone component displaying promoted quick filter keys and group-by preset links below the primary search box. + */ +@Component({ + selector: 'app-filter-presets', + standalone: true, + templateUrl: './filter_presets.ng.html', + styleUrl: './filter_presets.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], +}) +export class FilterPresets { + /** Shared search page state store injected via Angular Dependency Injection. */ + readonly store = inject(SearchPageStore); + + /** + * Handles user click on a promoted quick filter key link. + * Stops event propagation and opens the FilterValuePicker overlay anchored to the preset button element. + * + * @param k The selected `PromotedFilterKeyItem` configuration. + * @param anchor DOM button element acting as the anchor for the ValuePicker overlay. + * @param event Optional MouseEvent to prevent event bubbling. + */ + onSelectPromotedFilterKey( + k: PromotedFilterKeyItem, + anchor: HTMLElement, + event?: MouseEvent, + ) { + event?.stopPropagation(); + const displayName = k.metadata?.keyDisplayName || k.key; + this.store.openValuePicker(k.key, anchor, displayName, k.metadata); + } + + /** + * Handles user click on a promoted group-by quick preset link. + * Toggles the specified group-by key (adds it if not present, or removes it if already active). + * + * @param k The selected `PromotedGroupByKeyItem` configuration. + */ + onSelectPromotedGroupBy(k: PromotedGroupByKeyItem) { + this.store.toggleGroupBy(k.key, k.displayName); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.ng.html new file mode 100644 index 0000000000..ed80174051 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.ng.html @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.scss new file mode 100644 index 0000000000..2c372c36fa --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.scss @@ -0,0 +1,10 @@ +:host { + display: block; + width: 100%; +} + +.comp-area { + display: flex; + flex-direction: column; + width: 100%; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.ts new file mode 100644 index 0000000000..40e3cd7a57 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_box.ts @@ -0,0 +1,37 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + viewChild, +} from '@angular/core'; + +import {SearchPageStore} from '../../services/search_page_store'; +import {FilterPresets} from './filter_presets/filter_presets'; +import {SearchInput} from './search_input/search_input'; +import {FilterValuePicker} from '../filter_value_picker/filter_value_picker'; + +/** Compound component assembling search input, filter presets, and filter value picker. */ +@Component({ + selector: 'app-search-box', + standalone: true, + templateUrl: './search_box.ng.html', + styleUrl: './search_box.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + FilterPresets, + SearchInput, + FilterValuePicker, + ], +}) +export class SearchBox { + readonly store = inject(SearchPageStore); + + readonly searchInputComponent = viewChild(SearchInput); + + /** Delegates focus to the underlying SearchInput component. */ + focusInput() { + this.searchInputComponent()?.focusInput(); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.ng.html new file mode 100644 index 0000000000..90c59ecf54 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.ng.html @@ -0,0 +1,86 @@ +
+ + @if (store.showScopeSwitcher()) { +
+ + +
+ } + + +
+ + + + +
+ + + +
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.scss new file mode 100644 index 0000000000..09712e8d80 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.scss @@ -0,0 +1,224 @@ +:host { + display: block; + width: 100%; +} + +.query-top-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + width: 100%; + max-width: 960px; + margin-bottom: 12px; + + > *:not(.search-wrap) { + margin-top: 8px; + flex-shrink: 0; + } +} + +.scope-switcher { + display: inline-flex; + align-items: center; + border-radius: 20px; + overflow: hidden; + border: 1px solid #c4c7c5; + background-color: #f0f4f9; + box-sizing: border-box; + flex-shrink: 0; +} + +.scope-seg { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 20px; + min-width: 100px; + border: none; + cursor: pointer; + background: transparent; + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + letter-spacing: 0.1px; + color: #1f1f1f; + transition: background-color 0.2s ease, color 0.2s ease; + + & + .scope-seg { + border-left: 1px solid #c4c7c5; + } + + &.active { + background-color: #c2e7ff; + color: #001d35; + } + + &:hover:not(.active) { + background-color: rgba(29, 27, 32, 0.08); + } +} + +.search-wrap { + position: relative; + display: flex; + align-items: center; + flex: 1; + min-width: 0; + z-index: 10; +} + +.search-box { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 8px; + min-height: 56px; + padding: 8px 12px 8px 16px; + border-radius: 24px; + background-color: #fff; + border: 1px solid #c4c7c5; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + transition: min-height 0.28s cubic-bezier(0.2, 0, 0, 1), + max-height 0.28s cubic-bezier(0.2, 0, 0, 1), + padding 0.2s ease, box-shadow 0.2s, border-color 0.2s; + flex: 1; + min-width: 0; + box-sizing: border-box; + + &:focus-within { + border-color: #0b57d0; + box-shadow: 0 0 0 2px rgba(11, 87, 208, 0.2); + } +} + +.search-icon { + color: #444746; + margin-right: 4px; + align-self: center; + flex-shrink: 0; +} + +.search-input-area { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex: 1; + min-width: 0; +} + +.active-chips-container { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.search-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background-color: #d3e3fd; + color: #041e49; + border-radius: 8px; + padding: 2px 8px; + font-size: 0.8125rem; + height: 30px; + box-sizing: border-box; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; + white-space: nowrap; + + &.active { + outline: 2px solid #0b57d0; + outline-offset: -1px; + } + + &.exclude, + &.negated { + background-color: #fce9e7; + color: #8c1d18; + + .chip-remove { + color: #8c1d18; + + &:hover { + background-color: rgba(140, 29, 24, 0.12); + } + } + } + + .chip-label { + font-weight: 500; + } + + .chip-value { + font-weight: 600; + } + + .chip-remove { + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + color: #041e49; + font-size: 16px; + + .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } +} + +.search-input { + flex: 1; + min-width: 60px; + height: 32px; + border: none; + outline: none; + font-size: 1rem; + color: #1f1f1f; + background: transparent; +} + +.search-clear-btn, +.query-refresh-btn { + border: none; + background: transparent; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + padding: 0; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #444746; + flex-shrink: 0; + transition: background-color 0.2s; + + &:hover { + background-color: rgba(68, 71, 70, 0.08); + color: #1f1f1f; + } + + .mat-icon, + .material-symbols-outlined { + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; + margin: 0; + display: inline-block; + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.ts new file mode 100644 index 0000000000..bfbd5bdef0 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_input/search_input.ts @@ -0,0 +1,236 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + signal, + viewChild, +} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; + +import {SearchPageStore} from '../../../services/search_page_store'; +import { + ChipNegatedPipe, + FilterChip, + FilterKeyMetadata, + SearchBoxSuggestion, +} from '../../../utils'; +import {SearchSuggestions} from '../search_suggestions/search_suggestions'; + +/** + * Standalone component containing Fleet scope switcher, active filter chips, + * and primary search input with typeahead suggestions popover. + */ +@Component({ + selector: 'app-search-input', + standalone: true, + templateUrl: './search_input.ng.html', + styleUrl: './search_input.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ChipNegatedPipe, + MatButtonModule, + MatIconModule, + SearchSuggestions, + ], +}) +export class SearchInput { + /** Shared search page state store injected via Angular Dependency Injection. */ + readonly store = inject(SearchPageStore); + + /** Signal reference targeting the native HTML `` element inside the search bar. */ + readonly searchInput = viewChild>('searchInput'); + + /** Signal reference targeting the search box outer container DOM element for CDK overlay positioning. */ + readonly searchBoxOrigin = viewChild>('searchBox'); + + /** Active highlighted index during keyboard navigation over auto-complete suggestions (-1 when unselected). */ + readonly activeSuggestionIndex = signal(-1); + + /** + * Handles user selection of an auto-complete suggestion item. + * If the suggestion requests an open-picker action, opens the FilterValuePicker overlay anchored to the search box; + * otherwise, applies the suggestion filter directly to the store. + * + * @param event Object containing the selected `SearchBoxSuggestion` item. + */ + onSelectSuggestion(event: {item: SearchBoxSuggestion}) { + const {item} = event; + this.searchInput()?.nativeElement.blur(); + this.store.showSuggestions.set(false); + this.activeSuggestionIndex.set(-1); + + const raw = item.rawItem as + | {openPicker?: {key: string; keyDisplayName?: string; metadata?: FilterKeyMetadata}} + | undefined; + const op = raw?.openPicker; + if (op) { + const displayTitle = op.metadata?.keyDisplayName || op.keyDisplayName || op.key; + this.store.openValuePicker( + op.key, + this.searchBoxOrigin()?.nativeElement || null, + displayTitle, + op.metadata, + ); + return; + } + + this.store.selectSuggestion(item); + } + + /** + * Handles user text input events in the search box. + * Updates the search query signal, resets active suggestion highlight, and opens suggestions popover. + * + * @param val Current text value in the search input box. + */ + onSearchInput(val: string) { + this.store.searchQuery.set(val); + this.store.showSuggestions.set(true); + this.activeSuggestionIndex.set(-1); + } + + /** Focuses the native search input element and displays the suggestions popover. */ + focusInput() { + this.searchInput()?.nativeElement.focus(); + this.store.showSuggestions.set(true); + } + + /** Handles focus event on the input element by opening the suggestions popover. */ + onInputFocus() { + this.store.showSuggestions.set(true); + } + + /** + * Prevents click propagation from closing overlays and displays suggestions popover on input click. + * + * @param event Mouse event from input click. + */ + onInputClick(event?: MouseEvent) { + event?.stopPropagation(); + this.store.showSuggestions.set(true); + } + + /** + * Handles click events anywhere on the outer search box area. + * Delegates focus to the text input unless a chip or clear button was explicitly clicked. + * + * @param event Mouse event from search box container click. + */ + onSearchBoxClick(event?: MouseEvent) { + const target = event?.target as HTMLElement | null; + if (target?.closest('.search-chip') || target?.closest('.search-clear-btn')) { + return; + } + this.focusInput(); + this.store.showSuggestions.set(true); + } + + /** Handles blur event on the input element by hiding suggestions popover. */ + onInputBlur() { + this.store.showSuggestions.set(false); + } + + /** + * Dispatches keyboard navigation events for search input controls. + * Handles ArrowUp/ArrowDown for suggestion highlighting, Enter for selection/execution, and Escape for closing overlays. + * + * @param event Keyboard event triggered from the search input. + */ + onKeyDown(event: KeyboardEvent) { + const key = event.key; + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key)) return; + + const suggestions = this.store.suggestions(); + const isShowing = this.store.showSuggestions() && suggestions.length > 0; + + switch (key) { + case 'ArrowDown': + if (!isShowing) return; + event.preventDefault(); + this.activeSuggestionIndex.update((i) => (i + 1) % suggestions.length); + break; + + case 'ArrowUp': + if (!isShowing) return; + event.preventDefault(); + this.activeSuggestionIndex.update((i) => + i <= 0 ? suggestions.length - 1 : i - 1, + ); + break; + + case 'Enter': + this.handleEnterKey(event, isShowing, suggestions); + break; + + case 'Escape': + this.handleEscapeKey(); + break; + + default: + break; + } + + } + + /** + * Private helper executing selection or search query when the Enter key is pressed. + * + * @param event Keyboard event. + * @param isShowing Whether suggestions popover is active. + * @param suggestions Array of available search suggestions. + */ + private handleEnterKey( + event: KeyboardEvent, + isShowing: boolean, + suggestions: SearchBoxSuggestion[], + ) { + if (isShowing && suggestions.length > 0) { + event.preventDefault(); + const idx = this.activeSuggestionIndex(); + const targetIdx = idx >= 0 && idx < suggestions.length ? idx : 0; + this.onSelectSuggestion({item: suggestions[targetIdx]}); + return; + } + + if (this.store.searchQuery().trim()) { + this.store.showSuggestions.set(false); + this.store.executeSearch(); + } + } + + /** Private helper closing suggestions popover or value picker overlay when Escape key is pressed. */ + private handleEscapeKey() { + if (this.store.showSuggestions()) { + this.store.showSuggestions.set(false); + this.activeSuggestionIndex.set(-1); + return; + } + + if (this.store.showValuePicker()) { + this.store.closeValuePicker(); + } + } + + /** + * Opens the FilterValuePicker CDK Overlay popover to edit an active filter chip. + * + * @param chip The FilterChip to edit. + * @param anchor DOM element anchor where the ValuePicker overlay positions itself. + * @param event Optional MouseEvent to prevent event bubbling. + */ + openPickerForChip( + chip: FilterChip, + anchor: HTMLElement, + event?: MouseEvent, + ) { + event?.stopPropagation(); + if (chip.isGroupBy) return; + const key = chip.key || chip.pillKey; + const title = chip.metadata?.keyDisplayName || chip.pillKey; + this.store.openValuePicker(key, anchor, title, chip.metadata); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.ng.html new file mode 100644 index 0000000000..0fd21d78a8 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.ng.html @@ -0,0 +1,28 @@ +@if (store.showSuggestions() && store.suggestions().length > 0) { +
+
+ @for (item of store.suggestions(); track $index) { +
+ @if (item.label) { + {{ item.label }} + } + + @for (seg of item.mainText; track $index) { + {{ seg.text }} + } + + @if (item.count !== undefined && item.count !== null) { + + {{ item.countPrefix || '' }}{{ item.count | number }}{{ item.countUnit ? ' ' + item.countUnit : '' }} + + } +
+ } +
+
+} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.scss new file mode 100644 index 0000000000..abbc0c702d --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.scss @@ -0,0 +1,69 @@ +:host { + display: block; + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + z-index: 100; +} + +.search-suggestions-popover { + background: #fff; + border-radius: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid #c4c7c5; + overflow: hidden; + padding: 8px 0; +} + +.suggestion-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.suggestion-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + cursor: pointer; + font-size: 0.875rem; + color: #1f1f1f; + transition: background-color 0.15s ease; + min-height: 24px; + + &:hover, + &.active-suggestion { + background-color: #f0f4f9; + } + + .sg-label { + color: #444746; + opacity: 0.75; + font-size: 0.75rem; + } + + .sg-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .emphasized { + font-weight: 600; + } + } + + .sg-count { + font-size: 0.75rem; + font-weight: 500; + color: #444746; + flex-shrink: 0; + + &.over-max { + color: #b91c1c; + } + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.ts new file mode 100644 index 0000000000..dc32f5ca4e --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_box/search_suggestions/search_suggestions.ts @@ -0,0 +1,34 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + output, +} from '@angular/core'; + +import {SearchPageStore} from '../../../services/search_page_store'; +import {SearchBoxSuggestion} from '../../../utils'; + +/** Standalone popover component rendering typeahead search suggestions. */ +@Component({ + selector: 'app-search-suggestions', + standalone: true, + templateUrl: './search_suggestions.ng.html', + styleUrl: './search_suggestions.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], +}) +export class SearchSuggestions { + readonly store = inject(SearchPageStore); + + /** Currently highlighted keyboard navigation index. */ + readonly activeIndex = input(-1); + + /** Event emitted when a suggestion is selected. */ + readonly selectSuggestion = output<{item: SearchBoxSuggestion}>(); + + onItemClick(item: SearchBoxSuggestion) { + this.selectSuggestion.emit({item}); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.ng.html new file mode 100644 index 0000000000..37838ea2c4 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.ng.html @@ -0,0 +1,40 @@ + + + + + + + diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.scss new file mode 100644 index 0000000000..c085e694a8 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.scss @@ -0,0 +1,62 @@ +@use '@angular/material' as mat; + +:host { + display: inline-flex; + align-items: center; +} + +.rt-btn-text { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 32px; + padding: 0 10px; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + color: #0b57d0; + white-space: nowrap; + line-height: 1; + + &:hover:not(:disabled) { + background: rgba(11, 87, 208, 0.08); + } + + &:disabled { + color: #444746; + opacity: 0.38; + cursor: default; + } + + .mat-icon, + .material-symbols-outlined { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .rt-caret { + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; + margin-left: -2px; + } +} + +.active-density { + background-color: #e8f0fe; + color: #0b57d0; + font-weight: 500; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.ts new file mode 100644 index 0000000000..5c8195fc1b --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/density_dropdown/density_dropdown.ts @@ -0,0 +1,33 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import {MatMenuModule} from '@angular/material/menu'; + +/** Density option type for search results table rows. */ +export type TableDensity = 'compact' | 'default' | 'comfortable'; + +/** Reusable standalone density selector dropdown component for search results. */ +@Component({ + selector: 'app-density-dropdown', + standalone: true, + templateUrl: './density_dropdown.ng.html', + styleUrl: './density_dropdown.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, MatIconModule, MatMenuModule], +}) +export class DensityDropdownComponent { + /** Active table row height density mode. */ + readonly density = input('default'); + + /** Event emitted when user selects a different density option. */ + readonly densityChange = output(); + + setDensity(mode: TableDensity) { + this.densityChange.emit(mode); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.ng.html new file mode 100644 index 0000000000..0c9c61f329 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.ng.html @@ -0,0 +1,77 @@ +@if (cell(); as cellObj) { + @switch (getCellType(cellObj)) { + @case ('text') { + @if (renderTextValue(cellObj, column()?.key); as txtVal) { + {{ txtVal }} + } @else { + + } + } + @case ('link') { + @if (getRouterLink(cellObj.link); as linkArr) { + + {{ getTextValue(cellObj) }} + + } @else { + + } + } + @case ('status') { + @if (getTextValue(cellObj); as statusTxt) { + + + {{ statusTxt }} + + } @else { + + } + } + @case ('chips') { + @if (cellObj.chips?.values; as chipValues) { + @if (chipValues.length > 0) { + + } @else { + + } + } @else { + + } + } + @case ('multilink') { + @if (cellObj.multiLink?.entries; as entries) { + @if (entries.length > 0) { + @for (entry of entries; track entry.text || $index) { + @if (getRouterLink(entry); as linkArr) { + + {{ entry.text }} + + } @else { + {{ entry.text }} + } + @if (!$last) {, } + } + } @else { + + } + } @else { + + } + } + @default { + @if (renderTextValue(cellObj, column()?.key); as defVal) { + {{ defVal }} + } @else { + + } + } + } +} @else { + +} + + + - + diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.scss new file mode 100644 index 0000000000..79c9898621 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.scss @@ -0,0 +1,45 @@ +.status-cell { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.rt-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + + &.status-ok { + background-color: var(--mat-sys-positive, #34a853); + } + + &.status-active { + background-color: var(--mat-sys-primary, #1a73e8); + } + + &.status-error { + background-color: var(--mat-sys-error, #ea4335); + } + + &.status-neutral { + background-color: var(--mat-sys-outline, #9aa0a6); + } +} + +.device-link { + color: var(--mat-sys-primary, #1a73e8); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.rt-empty { + color: var(--mat-sys-outline, #9aa0a6); +} + +.tjs-list-item { + white-space: nowrap; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.ts new file mode 100644 index 0000000000..f380ba0f49 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_cell/search_cell.ts @@ -0,0 +1,51 @@ +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, input} from '@angular/core'; +import {RouterLink} from '@angular/router'; + +import {Cell, Column} from '../../../../../../core/models/search'; +import {OverflowChipListComponent} from '../../../../../../shared/components/overflow_chip_list/overflow_chip_list'; +import {dateUtils} from '../../../../../../shared/utils/date_utils'; +import { + formatTime, + getCellType, + getRouterLink, + getStatusClass, + getTextValue, + isTimeColumn, +} from '../../../../utils'; + +/** Standalone component for rendering a single search table cell generically based on Proto Cell type. */ +@Component({ + selector: 'app-search-cell', + standalone: true, + templateUrl: './search_cell.ng.html', + styleUrl: './search_cell.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, RouterLink, OverflowChipListComponent], +}) +export class SearchCellComponent { + readonly cell = input(); + readonly column = input(); + readonly entity = input('devices'); + + readonly getTextValue = getTextValue; + readonly getCellType = getCellType; + readonly getStatusClass = getStatusClass; + readonly getRouterLink = getRouterLink; + readonly dateUtils = dateUtils; + readonly formatTime = formatTime; + + /** Computes the final text string for plain text or timestamp cells to flatten template logic. */ + renderTextValue( + cell: Cell | null | undefined, + colKey: string | undefined, + ): string | null { + if (!cell) return null; + const txt = getTextValue(cell); + if (!txt) return null; + if (isTimeColumn(colKey)) { + return formatTime(txt) || txt; + } + return txt; + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.ng.html new file mode 100644 index 0000000000..05001cc9f0 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.ng.html @@ -0,0 +1,43 @@ +
+ @if (showPageSize()) { + Rows per page: + + @for (opt of pageSizeOptions(); track opt) { + {{ opt }} + } + + + } + + + {{ getRangeLabel() }} + + + + + + + +
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.scss new file mode 100644 index 0000000000..bd71346a0f --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.scss @@ -0,0 +1,169 @@ +@use '@angular/material' as mat; + +:host { + display: block; + width: 100%; +} + +.rt-pagination { + display: flex; + align-items: center; + gap: 4px; + height: 52px; + padding: 0 12px 0 16px; + flex-shrink: 0; + border-top: 1px solid #e1e3e1; + font-size: 0.8125rem; + color: #444746; + box-sizing: border-box; +} + +.rt-page-label { + margin-right: 8px; + color: #444746; + font-size: 0.8125rem; +} + +.rt-page-size { + width: auto; + min-width: 64px; + height: 32px; + padding: 0 8px; + border: 1px solid #c4c7c5; + border-radius: 6px; + background-color: #fff; + display: inline-flex; + align-items: center; + box-sizing: border-box; + + @include mat.select-overrides(( + trigger-text-size: 0.8125rem, + enabled-trigger-text-color: #1f1f1f, + )); + + ::ng-deep { + .mat-select-trigger, + .mat-mdc-select-trigger { + display: flex; + align-items: center; + height: 100%; + width: 100%; + } + + .mat-select-arrow-wrapper, + .mat-mdc-select-arrow-wrapper { + transform: none; + margin-left: 4px; + } + + .mat-select-arrow, + .mat-mdc-select-arrow { + color: #444746; + } + } + + &:hover { + border-color: #747775; + background-color: rgba(0, 0, 0, 0.02); + } + + &:focus-within { + border-color: #0b57d0; + } +} + +.rt-divider { + display: inline-block; + width: 1px; + height: 16px; + background-color: #e1e3e1; + margin: 0 12px; + flex-shrink: 0; +} + +.rt-range { + font-variant-numeric: tabular-nums; + color: #444746; + font-size: 0.8125rem; +} + +.rt-grow { + flex: 1; +} + +.rt-btn-text { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 32px; + padding: 0 10px; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + color: #0b57d0; + white-space: nowrap; + line-height: 1; + + &:hover:not(:disabled) { + background: rgba(11, 87, 208, 0.08); + } + + &:disabled { + color: #444746; + opacity: 0.38; + cursor: default; + } + + .mat-icon, + .material-symbols-outlined { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } +} + +::ng-deep .rt-page-size-panel { + @include mat.select-overrides(( + panel-background-color: #fff, + )); + + @include mat.option-overrides(( + focus-state-layer-color: #e8f0fe, + label-text-size: 0.8125rem, + label-text-color: #1f1f1f, + hover-state-layer-color: rgba(29, 27, 32, 0.08), + )); + + border: 1px solid #c4c7c5; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 4px 0; + min-width: 80px; + + .mat-option { + min-height: 36px; + height: 36px; + padding: 0 12px; + border-radius: 4px; + margin: 0 4px; + + &.mat-selected { + font-weight: 600; + } + + .mat-pseudo-checkbox { + display: none; + } + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.ts new file mode 100644 index 0000000000..31b8921649 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/common/search_pagination/search_pagination.ts @@ -0,0 +1,90 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import {MatSelectModule} from '@angular/material/select'; + +/** Reusable standalone pagination footer controls component for search results. */ +@Component({ + selector: 'app-search-pagination', + standalone: true, + templateUrl: './search_pagination.ng.html', + styleUrl: './search_pagination.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, MatIconModule, MatSelectModule], +}) +export class SearchPaginationComponent { + /** Current zero-indexed page number. */ + readonly pageIndex = input(0); + + /** Active page size count. */ + readonly pageSize = input(25); + + /** Number of rows returned on the current page. */ + readonly currentRowCount = input(0); + + /** Optional explicit total record count. */ + readonly totalCount = input(undefined); + + /** Explicit start record index for current page range display. */ + readonly rangeStart = input(undefined); + + /** Explicit end record index for current page range display. */ + readonly rangeEnd = input(undefined); + + /** Optional custom text override for the page range label. */ + readonly customRangeText = input(undefined); + + /** Optional explicit indicator of whether a previous page exists. */ + readonly hasPrevPage = input(undefined); + + /** Whether a subsequent page of results is available. */ + readonly hasNextPage = input(false); + + /** Controls display of the 'Rows per page' dropdown selector. */ + readonly showPageSize = input(false); + + /** Available options for page size selection dropdown. */ + readonly pageSizeOptions = input([10, 25, 50, 100]); + + /** Event emitted when user clicks the previous page button. */ + readonly prev = output(); + + /** Event emitted when user clicks the next page button. */ + readonly next = output(); + + /** Event emitted when user changes the page size selection. */ + readonly pageSizeChange = output(); + + /** Computes formatted page range display label string. */ + readonly getRangeLabel = computed(() => { + if (this.customRangeText()) { + return this.customRangeText()!; + } + const start = + this.rangeStart() ?? this.pageIndex() * this.pageSize() + 1; + const end = + this.rangeEnd() ?? + this.pageIndex() * this.pageSize() + this.currentRowCount(); + if (this.totalCount() != null) { + return `${start.toLocaleString()}–${end.toLocaleString()} of ${this.totalCount()!.toLocaleString()}`; + } + return `showing ${start.toLocaleString()}–${end.toLocaleString()}`; + }); + + /** Computes whether previous page button is enabled. */ + readonly canGoPrev = computed(() => { + if (this.hasPrevPage() != null) { + return this.hasPrevPage()!; + } + return this.pageIndex() > 0; + }); + + /** Computes whether next page button is enabled. */ + readonly canGoNext = computed(() => this.hasNextPage()); +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.ng.html new file mode 100644 index 0000000000..93c673bf72 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.ng.html @@ -0,0 +1,151 @@ +
+ +
+ + + + @if (group().utilization && (group().utilization?.total || 0) > 0) { +
+
+
+
+
+
+
+
+ + + {{ (group().utilization!.busy || 0) | number }} ({{ group().utilization!.busy | appUtilPct:group().utilization!.total }}%) + + + + {{ (group().utilization!.idle || 0) | number }} ({{ group().utilization!.idle | appUtilPct:group().utilization!.total }}%) + + + + {{ (group().utilization!.other || 0) | number }} ({{ group().utilization!.other | appUtilPct:group().utilization!.total }}%) + +
+
+
+ } +
+ + + @if (isOpen()) { +
+ @if (groupState(); as state) { + @if (state.loading) { +
Loading {{ entity() }}...
+ } @else if (state.error) { +
+ {{ state.error }} +
+ } @else if (state.data?.rows && state.data!.rows!.length > 0) { +
+ + + + + @for (col of state.data!.columns || columns(); track col.key) { + + } + + + + @for (row of state.data!.rows!; track getRowId(row)) { + + + @for (col of state.data!.columns || columns(); track col.key) { + + } + + } + +
+ + {{ col.displayName }}
+ + + +
+
+ + +
+ + {{ state.data!.rangeStart || 0 }}–{{ state.data!.rangeEnd || 0 }} of + {{ (state.data!.total || 0) | number }} + + + + +
+ } @else { +
No {{ entity() }} in this group.
+ } + } @else { +
Loading {{ entity() }}...
+ } +
+ } +
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.scss new file mode 100644 index 0000000000..d5951d3266 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.scss @@ -0,0 +1,403 @@ +@use '@angular/material' as mat; +@use '../../../../../shared/styles/common'; + +:host { + display: block; + width: 100%; +} + +.rt-group { + border: 1px solid #c4c7c5; + border-radius: 12px; + overflow: hidden; + background: #fff; + flex-shrink: 0; +} + +.rt-group-row { + display: flex; + align-items: center; + gap: 4px; + padding-right: 16px; + background: #f8fafd; + transition: background-color 0.2s cubic-bezier(0.2, 0, 0, 1); + flex-shrink: 0; + + &:hover { + background: #e9eef6; + } + + &.open { + background: #c2e7ff; + + &:hover { + background: #b3dcf6; + } + } +} + +.rt-group-head { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; + min-height: 56px; + padding: 8px 16px; + border: none; + cursor: pointer; + background: transparent; + font-family: inherit; + font-size: 0.875rem; + text-align: left; + color: #1f1f1f; + + &:focus-visible { + outline: 2px solid #0b57d0; + outline-offset: -2px; + } +} + +.rt-caret-icon { + font-size: 20px; + flex-shrink: 0; + color: #444746; +} + +.rt-group-name { + flex: 1; + min-width: 0; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rt-gv-sep { + color: #444746; + margin: 0 2px; +} + +.rt-group-count { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + color: #444746; +} + +.rt-util { + display: flex; + align-items: center; + flex-shrink: 0; + width: 280px; + + &:has(.util-legend-item:nth-of-type(1):hover) .progress-segment.color-busy, + &:has(.util-legend-item:nth-of-type(2):hover) .progress-segment.color-idle, + &:has(.util-legend-item:nth-of-type(3):hover) .progress-segment.color-others { + filter: brightness(1.15); + transform: scaleY(1.6); + } +} + +.rt-util-wrapper { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; +} + +.progress-container { + display: flex; + height: 6px; + width: 100%; + margin: 0; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05); + overflow: visible; + background-color: #e0e0e0; + box-sizing: border-box; + + &:has(.progress-segment.color-busy:hover) + div .util-legend-item:nth-of-type(1), + &:has(.progress-segment.color-idle:hover) + div .util-legend-item:nth-of-type(2), + &:has(.progress-segment.color-others:hover) + div .util-legend-item:nth-of-type(3) { + color: #0b57d0; + text-decoration-color: #0b57d0; + font-weight: 500; + } +} + +.progress-segment { + height: 100%; + transition: width 0.3s ease, transform 0.2s ease, filter 0.2s ease; + transform-origin: center; + cursor: pointer; + + &:hover { + filter: brightness(1.15); + transform: scaleY(1.6); + } + + &.color-busy { + background-color: #0b57d0; + } + &.color-idle { + background-color: #146c2e; + } + &.color-others { + background-color: #747775; + } +} + +.legend-dot { + &.color-busy { + background-color: #0b57d0; + } + &.color-idle { + background-color: #146c2e; + } + &.color-others { + background-color: #747775; + } +} + +.rt-util-legend-bar { + font-size: 0.6875rem; + color: #5f6368; + display: flex; + justify-content: space-between; +} + +.util-legend-item { + display: inline-flex; + align-items: center; + font-size: 0.6875rem; + color: #444746; + cursor: pointer; + text-decoration: underline; + text-decoration-color: #c4c7c5; + transition: color 0.15s ease, text-decoration-color 0.15s ease; + + &:hover { + color: #0b57d0; + text-decoration-color: #0b57d0; + font-weight: 500; + } + + .legend-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 4px; + } +} + +.rt-group-body { + padding: 0; + background: #fff; + max-height: 40vh; + display: flex; + flex-direction: column; +} + +.rt-group-scroll { + flex: 1; + overflow: auto; + min-height: 0; + @include common.shared-scrollbar-style(); +} + +.rt-inner-table { + table-layout: auto; + width: 100%; + min-width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.8125rem; + text-align: left; + direction: ltr; + + thead th { + position: sticky; + top: 0; + z-index: 10; + height: 32px; + padding: 0 12px; + font-size: 0.75rem; + font-weight: 600; + color: #444746; + background: #f3f4f9; + border-bottom: 1px solid #c4c7c5; + white-space: nowrap; + text-transform: uppercase; + text-align: left; + direction: ltr; + } + + tbody tr { + height: 32px; + } + + tbody td { + padding: 2px 12px; + border-bottom: 1px solid #e1e3e1; + color: #1f1f1f; + white-space: nowrap; + text-align: left; + direction: ltr; + } + + tr:hover { + background-color: #f1f5fb; + } + + tr.selected-row { + background-color: #0b57d008; + } + + th.rt-check-cell, + td.rt-check-cell, + .rt-check-cell { + width: 44px; + padding: 0; + text-align: center; + vertical-align: middle; + text-overflow: clip; + overflow: visible; + + mat-checkbox { + @include mat.checkbox-density(-3); + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + height: 100%; + margin: 0 auto; + } + } +} + +.rt-inner-pagination { + flex-shrink: 0; + height: 40px; + padding: 0 12px; + font-size: 0.75rem; + border-top: 1px solid #e1e3e1; + display: flex; + align-items: center; + + .rt-btn-text { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 28px; + height: 28px; + padding: 0 8px; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + color: #0b57d0; + white-space: nowrap; + line-height: 1; + + &:hover:not(:disabled) { + background: rgba(11, 87, 208, 0.08); + } + + &:disabled { + color: #444746; + opacity: 0.38; + cursor: default; + } + + .mat-icon, + .material-symbols-outlined { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + } +} + +.rt-range { + font-variant-numeric: tabular-nums; + color: #444746; + font-size: 0.75rem; +} + +.rt-grow { + flex: 1; +} + +.rt-group-loading, +.rt-group-empty { + padding: 16px; + font-size: 0.875rem; + color: #5f6368; +} + +.rt-group-error-msg { + color: #d93025; +} + +.rt-empty { + color: #747775; + font-style: italic; +} + +[class*='col-'][class*='id'] { + width: 220px; + min-width: 160px; +} + +[class*='col-'][class*='status'] { + width: 110px; + min-width: 100px; +} + +[class*='col-'][class*='type'], +[class*='col-'][class*='driver'] { + width: 260px; + min-width: 220px; +} + +[class*='col-'][class*='owner'], +[class*='col-'][class*='user'] { + width: 180px; + min-width: 120px; +} + +[class*='col-'][class*='time'] { + width: 180px; + min-width: 140px; +} + +[class*='col-'][class*='duration'] { + width: 120px; + min-width: 90px; +} + +[class*='col-'][class*='result'] { + width: 120px; + min-width: 90px; +} + +[class*='col-'][class*='host'] { + width: 240px; + min-width: 180px; +} + +[class*='col-'][class*='model'] { + width: 180px; + min-width: 140px; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.ts new file mode 100644 index 0000000000..f58dc36299 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_group_card/fleet_group_card.ts @@ -0,0 +1,115 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatIconModule} from '@angular/material/icon'; + +import { + Column, + FleetGroup, + Row, +} from '../../../../../core/models/search'; +import { + SearchCellPipe, + UtilPctPipe, + UtilWidthPipe, +} from '../../../utils'; +import {SearchCellComponent} from '../common/search_cell/search_cell'; + +/** Data state structure for an expanded group card page. */ +export interface GroupPageState { + readonly loading?: boolean; + readonly error?: string; + readonly data?: { + readonly rows?: Row[]; + readonly columns?: Column[]; + readonly rangeStart?: number; + readonly rangeEnd?: number; + readonly total?: number; + readonly prevPageToken?: string; + readonly nextPageToken?: string; + }; +} + +/** Standalone component representing a single expandable group card in grouped results view. */ +@Component({ + selector: 'app-fleet-group-card', + standalone: true, + templateUrl: './fleet_group_card.ng.html', + styleUrl: './fleet_group_card.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatCheckboxModule, + MatIconModule, + SearchCellComponent, + SearchCellPipe, + UtilPctPipe, + UtilWidthPipe, + ], +}) +export class FleetGroupCardComponent { + /** Fleet group header card data object. */ + readonly group = input.required(); + + /** Whether this group card accordion is currently open/expanded. */ + readonly isOpen = input(false); + + /** Async state for lazy-loaded group row data. */ + readonly groupState = input(); + + /** Target search entity name ('devices' or 'hosts'). */ + readonly entity = input('devices'); + + /** Default table column definitions. */ + readonly columns = input([]); + + /** Currently selected row ID set. */ + readonly selectedSet = input>(new Set()); + + /** Event emitted when user clicks group header button to toggle expansion. */ + readonly toggleGroup = output(); + + /** Event emitted when user clicks select-all checkbox for a group's page. */ + readonly toggleSelectGroupPage = output(); + + /** Event emitted when user clicks checkbox for an individual row inside group. */ + readonly toggleSelectRow = output(); + + /** Event emitted when user requests inner page change (token passed). */ + readonly loadGroupPage = output(); + + getRowId(row: Row | undefined): string { + if (!row) return ''; + const rawId = row.id || ''; + if ( + rawId && + rawId !== '000000000000' && + rawId !== '00000000-0000-0000-0000-000000000000' + ) { + return rawId; + } + const c0 = row.cells?.[0]; + if (c0) { + const link = c0.link; + const target = link?.target; + const cellText = + target?.device?.id || + target?.host?.hostName || + link?.text || + c0.text?.value; + if (cellText) return String(cellText); + } + return rawId; + } + + isGroupPageAllSelected(rows: Row[] | undefined): boolean { + if (!rows || rows.length === 0) return false; + const selected = this.selectedSet(); + return rows.every((r) => selected.has(this.getRowId(r))); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.ng.html new file mode 100644 index 0000000000..1e4951f4f4 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.ng.html @@ -0,0 +1,300 @@ + + @if (groupByKeys().length > 0) { + +
+ @if (selected().size > 0) { + + {{ selected().size | number }} selected · {{ fleet() === 'internal' ? 'Google Internal' : 'ATS Labs' }} + + + + @if (entity() === 'devices') { + + + } + + + + + + } @else { + + {{ (groupedResults()?.totalItems || 0) | number }} {{ entity() }} in + {{ (groupedResults()?.totalGroups || 0) | number }} groups · {{ fleet() === 'internal' ? 'Google Internal' : 'ATS Labs' }} + + + @if (groupKeysText()) { + by {{ groupKeysText() }} + + } + + + + + @for (opt of groupSortOptions(); track opt.value) { + + } + + + + + + + + + @if (entity() === 'devices') { +
+ + In Service (BUSY) + + + In Service (IDLE) + + + Others + +
+ } + } +
+ + @if (store.isLoading()) { + + } +
+ @if (groups().length > 0) { + @for (g of groups(); track g.groupId) { + + } + } @else { + @if (!store.isLoading()) { +
No groups found.
+ } + } +
+ + + + } @else { + +
+ @if (selectedCount() > 0) { + + @if (selectAllMatching()) { + All {{ selectedCount() | number }} matching {{ entity() }} selected · {{ fleet() === 'internal' ? 'Google Internal' : 'ATS Labs' }} + } @else { + {{ selectedCount() | number }} selected · {{ fleet() === 'internal' ? 'Google Internal' : 'ATS Labs' }} + } + + + @if (!selectAllMatching()) { + + @if (entity() === 'devices') { + + + } + } + + + + + + + } @else { + + {{ totalCount() | number }} {{ entity() }} · {{ fleet() === 'internal' ? 'Google Internal' : 'ATS Labs' }} + + + + + + + + + + + + + + + + + + } +
+ + @if (store.isLoading()) { + + } + + + + @if (showSelectAllMatchingBanner()) { +
+ info + @if (selectAllMatching()) { + + All {{ totalCount() | number }} {{ entity() }} matching your filters are selected + @if (excludedCount() > 0) { + ({{ excludedCount() | number }} excluded) + } + . + + + } @else { + All {{ rows().length }} {{ entity() }} on this page are selected. + + } +
+ } + + +
+ + + + + + + + + @for (col of columns(); track col.key) { + + + + + } + + + + + + + + +
+ + + + + + {{ col.displayName }} + @if (isSortColumn(col.key)) { + + {{ sortAsc() ? 'arrow_upward' : 'arrow_downward' }} + + } + + + +
+ No matching {{ entity() }} found. +
+
+
+ + + + } +
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.scss new file mode 100644 index 0000000000..17d0eaa453 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.scss @@ -0,0 +1,242 @@ +@use '@angular/material' as mat; +@use '../../../../../shared/styles/common'; +@use '../../../../../shared/styles/search_table_common' as st; + +:host { + @include st.search-table-base; +} + +// --- Checkbox Column --- +th.checkbox-col, +td.checkbox-col, +.mat-mdc-header-cell.checkbox-col, +.mat-mdc-cell.checkbox-col, +.mat-column-select, +.mat-mdc-column-select { + width: 48px; + padding: 0; + text-align: center; + vertical-align: middle; + text-overflow: clip; + overflow: visible; + + mat-checkbox { + @include mat.checkbox-density(-3); + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + height: 100%; + margin: 0 auto; + } +} + +// --- Grouped Accordion Container Styles --- +mat-card.results-area.grouped-mode, +.results-area.grouped-mode { + background: transparent; + border: none; + box-shadow: none; + height: 100%; + max-height: 100%; + min-height: 0; + + .rt-toolbar { + background: transparent; + border-radius: 0; + margin-bottom: 12px; + flex-shrink: 0; + } +} + +.rt-groupby-label { + font-size: 0.8125rem; + color: #444746; +} + +.rt-util-key { + display: flex; + align-items: center; + gap: 16px; + font-size: 0.75rem; + color: #444746; +} + +.rt-util-key-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.rt-util-key .legend-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + + &.color-busy { + background-color: #0b57d0; + } + &.color-idle { + background-color: #146c2e; + } + &.color-others { + background-color: #747775; + } +} + +.rt-accordion { + flex: 1; + min-height: 0; + max-height: 100%; + overflow: hidden auto; + padding: 0 4px 0 0; + display: flex; + flex-direction: column; + gap: 8px; + @include common.shared-scrollbar-style(); + scrollbar-color: #bdc1c6 transparent; + transition: opacity 0.15s ease-in-out; + + &.stale-loading { + opacity: 0.6; + pointer-events: none; + } + + &::-webkit-scrollbar-thumb { + background-color: #bdc1c6; + } +} + +.rt-group-empty { + padding: 16px; + font-size: 0.875rem; + color: #5f6368; +} + +// --- Select-All-Matching Banner --- +.rt-banner { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + padding: 10px 16px; + font-size: 0.8125rem; + border-bottom: 1px solid #c4c7c5; + transition: all 0.2s ease-in-out; + + .material-symbols-outlined, + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + } + + span b { + font-weight: 600; + } + + .rt-btn-text { + background: transparent; + border: none; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: #0b57d0; + margin-left: 8px; + text-decoration: none; + border-radius: 20px; + padding: 0 12px; + + &:hover { + background: rgba(11, 87, 208, 0.08); + } + } +} + +.rt-banner-info { + background: #e8f0fe; + color: #1a3d6d; +} + +.rt-banner-error { + background: #fce8e6; + color: #9a2016; +} + +.rt-excluded { + opacity: 0.75; + font-style: italic; +} + +// --- Column Widths --- +[class*='col-'][class*='id'] { + width: 220px; + min-width: 160px; +} + +[class*='col-'][class*='status'] { + width: 110px; + min-width: 100px; +} + +[class*='col-'][class*='type'], +[class*='col-'][class*='driver'] { + width: 260px; + min-width: 220px; +} + +[class*='col-'][class*='owner'], +[class*='col-'][class*='user'] { + width: 180px; + min-width: 120px; +} + +[class*='col-'][class*='time'] { + width: 180px; + min-width: 140px; +} + +[class*='col-'][class*='duration'] { + width: 120px; + min-width: 90px; +} + +[class*='col-'][class*='result'] { + width: 120px; + min-width: 90px; +} + +[class*='col-'][class*='host'] { + width: 240px; + min-width: 180px; +} + +[class*='col-'][class*='model'] { + width: 180px; + min-width: 140px; +} + +[class*='col-'][class*='battery'] { + width: 110px; + min-width: 90px; +} + +[class*='col-'][class*='sdk'] { + width: 110px; + min-width: 90px; +} + +[class*='col-'][class*='name'] { + width: 400px; + min-width: 320px; +} + +[class*='col-'][class*='device'] { + width: 240px; + min-width: 180px; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.ts new file mode 100644 index 0000000000..eb8c4a44b9 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/fleet_search_results/fleet_search_results.ts @@ -0,0 +1,332 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + output, + signal, +} from '@angular/core'; +import {MatCardModule} from '@angular/material/card'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatIconModule} from '@angular/material/icon'; +import {MatMenuModule} from '@angular/material/menu'; +import {MatProgressBarModule} from '@angular/material/progress-bar'; +import {MatSelectModule} from '@angular/material/select'; +import {MatTableModule} from '@angular/material/table'; +import {RouterLink} from '@angular/router'; + +import { + Column, + FleetGroup, + FleetPromotedGroupByKey, + Row, +} from '../../../../../core/models/search'; +import {FleetSearchStore} from '../../../services/fleet_search_store'; +import {getRowId, SearchCellPipe} from '../../../utils'; + +import {DensityDropdownComponent} from '../common/density_dropdown/density_dropdown'; +import {FleetGroupCardComponent} from '../fleet_group_card/fleet_group_card'; +import {SearchPaginationComponent} from '../common/search_pagination/search_pagination'; +import {SearchCellComponent} from '../common/search_cell/search_cell'; + +/** Component representing search results for lab fleet (devices/hosts). */ +@Component({ + selector: 'app-fleet-search-results', + standalone: true, + templateUrl: './fleet_search_results.ng.html', + styleUrl: './fleet_search_results.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatCardModule, + MatCheckboxModule, + MatIconModule, + MatMenuModule, + MatProgressBarModule, + MatSelectModule, + MatTableModule, + RouterLink, + DensityDropdownComponent, + FleetGroupCardComponent, + SearchPaginationComponent, + SearchCellComponent, + SearchCellPipe, + ], +}) +export class FleetSearchResultsComponent { + readonly store = inject(FleetSearchStore); + + // Context Delegates + readonly columns = this.store.displayColumns; + readonly rows = this.store.rows; + readonly groupedResults = this.store.groupedResults; + readonly expandedGroupPages = this.store.expandedGroupPages; + readonly entity = this.store.entity; + readonly fleet = this.store.fleet; + readonly groupByKeys = this.store.groupByKeys; + readonly totalCount = this.store.effectiveTotalCount; + readonly rangeStart = this.store.effectiveRangeStart; + readonly rangeEnd = this.store.effectiveRangeEnd; + readonly pageIndex = this.store.pageIndex; + readonly pageSize = this.store.pageSize; + readonly density = this.store.density; + readonly selected = this.store.selectedItems; + readonly sortColumn = this.store.sortColumn; + readonly sortAsc = this.store.sortAsc; + readonly visibleColumns = this.store.visibleColumns; + readonly availableColumns = this.store.availableColumns; + + // Select-All-Matching Banner State + readonly selectAllMatching = signal(false); + readonly excluded = signal>(new Set()); + + readonly selectedCount = computed(() => { + if (this.selectAllMatching()) { + return Math.max(0, this.totalCount() - this.excluded().size); + } + return this.selected().size; + }); + + readonly excludedCount = computed(() => { + return this.excluded().size; + }); + + /** + * Whether to show the select-all-matching banner. + * + * This is shown when the user has selected all rows on the current page, + * and there are more rows available in the search results. + */ + readonly showSelectAllMatchingBanner = computed(() => { + if (this.groupByKeys().length > 0) return false; + if (this.selectAllMatching()) return true; + + const visibleIds = this.visibleRowIds(); + const allPageSelected = + visibleIds.length > 0 && + visibleIds.every((id: string) => this.selected().has(id)); + + return allPageSelected && this.totalCount() > visibleIds.length; + }); + + // Action Outputs + readonly refresh = output(); + readonly export = output(); + readonly quarantine = output(); + readonly configureWifi = output(); + readonly configure = output(); + + + prevPage() { + this.store.prevPage(); + } + + nextPage() { + this.store.nextPage(); + } + + // --- Group Accordion State (Delegated to Store) --- + readonly openGroupIds = this.store.openGroupIds; + readonly groupSort = this.store.groupSort; + readonly groupSortOptions = this.store.groupSortOptions; + + readonly groupSortActiveLabel = computed(() => { + const activeVal = this.groupSort(); + const options = this.groupSortOptions(); + const hit = options.find((o) => o.value === activeVal); + if (hit) return hit.label; + + if (activeVal === 'name:asc' || activeVal === 'name:desc') { + const isAsc = activeVal === 'name:asc'; + const firstGb = this.store.groupByKeys()[0]; + const meta = firstGb ? this.store.keyMetadataMap().get(firstGb) : undefined; + const name = meta?.keyDisplayName || firstGb || 'Name'; + return `${name} (${isAsc ? 'A–Z' : 'Z–A'})`; + } + + return 'Sort'; + }); + + readonly groupedRangeText = computed(() => { + const res = this.groupedResults(); + if (!res) return ''; + const start = res.rangeStart || 0; + const end = res.rangeEnd || 0; + const total = (res.totalGroups || 0).toLocaleString(); + return `${start}–${end} of ${total} groups`; + }); + + setGroupSort(value: string) { + this.store.setGroupSort(value); + } + + // --- Derived Signals (computed) --- + readonly columnsToDisplay = computed(() => { + const cols = this.columns().map((col: Column) => col.key); + if (cols.length === 0) return []; + return ['checkbox', ...cols]; + }); + + readonly totalPages = computed(() => { + return Math.ceil(this.totalCount() / this.pageSize()) || 1; + }); + + readonly groupKeysText = computed(() => { + const keys = this.groupedResults()?.groupByKeys || []; + return keys + .map((k: FleetPromotedGroupByKey) => k.displayName || k.key) + .join(' × '); + }); + + readonly groups = computed(() => { + return this.groupedResults()?.groups || []; + }); + + toggleGroup(groupId: string) { + this.store.toggleGroup(groupId); + } + + readonly getRowId = getRowId; + + toggleSelectGroupPage(rows: Row[] | undefined) { + if (!rows || rows.length === 0) return; + const current = new Set(this.selected()); + const allSelected = rows.every((r) => current.has(this.getRowId(r))); + if (allSelected) { + rows.forEach((r) => { current.delete(this.getRowId(r)); }); + } else { + rows.forEach((r) => { current.add(this.getRowId(r)); }); + } + this.selected.set(current); + } + + // --- Template Helper Methods --- + clearSelection() { + this.selected.set(new Set()); + this.selectAllMatching.set(false); + this.excluded.set(new Set()); + } + + /** + * Computed list of unique row IDs currently visible on the page. + * Disambiguates duplicate or missing IDs by appending index counter suffixes + * to guarantee row uniqueness in selection Sets. + */ + readonly visibleRowIds = computed(() => { + const rows = this.rows(); + const idCounts = new Map(); + return rows.map((r, i) => { + let id = this.getRowId(r); + if (!id) id = `row_${i}`; + const count = idCounts.get(id) || 0; + idCounts.set(id, count + 1); + // Append counter suffix for duplicate IDs on the same page + return count > 0 ? `${id}_${count}` : id; + }); + }); + + onPageSizeChange(newSize: number) { + this.store.pageSize.set(newSize); + this.store.pageIndex.set(0); + this.store.pageToken.set(''); + this.clearSelection(); + } + + // --- Selection Handlers --- + isAllSelected(): boolean { + if (this.selectAllMatching()) { + return this.excluded().size === 0; + } + const visibleIds = this.visibleRowIds(); + return ( + visibleIds.length > 0 && + visibleIds.every((id: string) => this.selected().has(id)) + ); + } + + isSomeSelected(): boolean { + if (this.selectAllMatching()) { + return ( + this.excluded().size > 0 && this.excluded().size < this.totalCount() + ); + } + const visibleIds = this.visibleRowIds(); + const selectedCount = visibleIds.filter((id: string) => + this.selected().has(id), + ).length; + return selectedCount > 0 && selectedCount < visibleIds.length; + } + + toggleSelectAll() { + if (this.selectAllMatching()) { + this.clearSelection(); + return; + } + const visibleIds = this.visibleRowIds(); + const allSelected = visibleIds.every((id: string) => + this.selected().has(id), + ); + if (allSelected) { + this.clearSelection(); + } else { + const nextSet = new Set(this.selected()); + visibleIds.forEach((id: string) => { nextSet.add(id); }); + this.selected.set(nextSet); + } + } + + toggleSelectRow(id: string) { + if (this.selectAllMatching()) { + const currentExcluded = new Set(this.excluded()); + if (currentExcluded.has(id)) { + currentExcluded.delete(id); + } else { + currentExcluded.add(id); + } + this.excluded.set(currentExcluded); + if (currentExcluded.size === this.totalCount()) { + this.clearSelection(); + } + } else { + const nextSet = new Set(this.selected()); + if (nextSet.has(id)) { + nextSet.delete(id); + } else { + nextSet.add(id); + } + this.selected.set(nextSet); + } + } + + isRowSelected(id: string): boolean { + if (this.selectAllMatching()) { + return !this.excluded().has(id); + } + return this.selected().has(id); + } + + selectActionAllMatching() { + this.selectAllMatching.set(true); + this.excluded.set(new Set()); + } + + isSortColumn(colKey: string): boolean { + const sortCol = this.sortColumn(); + if (!sortCol) return false; + if (sortCol === colKey) return true; + const isIdOrUuidSort = ['id', 'uuid', 'field::uuid'].includes(sortCol); + const isIdOrUuidCol = ['id', 'uuid', 'field::uuid'].includes(colKey); + if (isIdOrUuidSort && isIdOrUuidCol) return true; + return false; + } + + onHeaderClick(colKey: string) { + let asc = true; + if (this.isSortColumn(colKey)) { + asc = !this.sortAsc(); + } + this.sortColumn.set(colKey); + this.sortAsc.set(asc); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.ng.html new file mode 100644 index 0000000000..d5f73b213f --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.ng.html @@ -0,0 +1,63 @@ + + @if (store.activeChips().length === 0 && !store.browseAll() && !store.searchQuery().trim()) { +
Add a filter to search {{ entity() }}.
+ } @else { + +
+ {{ tjsNounPlural() }} + + +
+ + @if (isLoading()) { + + } + + @if (isLoading() && rows().length === 0) { +
Loading {{ tjsNounPlural() }}…
+ } @else if (!isLoading() && rows().length === 0) { +
No matching {{ entity() }}.
+ } @else { + + +
+ + + @for (col of columns(); track col.key) { + + + + + } + + @if (columnsToDisplay().length > 0) { + + } + +
+ {{ col.displayName }} + + +
+
+
+ + + + } + } +
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.scss new file mode 100644 index 0000000000..fc66f34495 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.scss @@ -0,0 +1,62 @@ +@use '../../../../../shared/styles/search_table_common' as st; + +:host { + @include st.search-table-base; +} + +.results-area.tjs-mode { + .results-table { + th, + .mat-mdc-header-cell, + .mat-header-cell { + height: 56px; + padding: 0 16px; + font-size: 0.875rem; + font-weight: 500; + text-transform: none; + color: #444746; + background-color: #f3f4f9; + border-bottom: 1px solid #c4c7c5; + cursor: default; + user-select: none; + } + + td { + padding: 0 16px; + border-bottom: 1px solid #e1e3e1; + } + } +} + +.tjs-empty { + padding: 48px; + text-align: center; + color: #5f6368; +} + +// --- Column Widths --- +[class*='col-'][class*='id'] { + width: 220px; + min-width: 160px; +} + +[class*='col-'][class*='status'] { + width: 110px; + min-width: 100px; +} + +[class*='col-'][class*='time'] { + width: 180px; + min-width: 140px; +} + +[class*='col-'][class*='duration'] { + width: 120px; + min-width: 90px; +} + +[class*='col-'][class*='user'], +[class*='col-'][class*='owner'] { + width: 180px; + min-width: 120px; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.ts new file mode 100644 index 0000000000..df93f05b3a --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/components/search_results/tjs_search_results/tjs_search_results.ts @@ -0,0 +1,71 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; +import {MatCardModule} from '@angular/material/card'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressBarModule} from '@angular/material/progress-bar'; +import {MatTableModule} from '@angular/material/table'; + +import {Column} from '../../../../../core/models/search'; +import {TjsSearchStore} from '../../../services/tjs_search_store'; +import {SearchCellPipe} from '../../../utils'; +import {DensityDropdownComponent} from '../common/density_dropdown/density_dropdown'; +import {SearchPaginationComponent} from '../common/search_pagination/search_pagination'; +import {SearchCellComponent} from '../common/search_cell/search_cell'; + +/** Component representing search results for TJS search entity types. */ +@Component({ + selector: 'app-tjs-search-results', + standalone: true, + templateUrl: './tjs_search_results.ng.html', + styleUrl: './tjs_search_results.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatCardModule, + MatIconModule, + MatProgressBarModule, + MatTableModule, + DensityDropdownComponent, + SearchPaginationComponent, + SearchCellComponent, + SearchCellPipe, + ], +}) +export class TjsSearchResultsComponent { + readonly store = inject(TjsSearchStore); + + // Context Delegates + readonly columns = this.store.displayColumns; + readonly rows = this.store.rows; + readonly entity = this.store.entity; + readonly pageIndex = this.store.pageIndex; + readonly pageSize = this.store.pageSize; + readonly density = this.store.density; + readonly isLoading = this.store.isLoading; + + // Derived Signals + readonly columnsToDisplay = computed(() => { + return this.columns().map((col: Column) => col.key); + }); + + prevPage() { + this.store.prevPage(); + } + + nextPage() { + this.store.nextPage(); + } + + readonly tjsNounPlural = computed(() => { + const e = this.entity(); + if (e === 'tests') return 'Tests'; + if (e === 'jobs') return 'Jobs'; + if (e === 'sessions') return 'Sessions'; + return e; + }); +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/index.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/index.ts new file mode 100644 index 0000000000..3cae2e0cad --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/index.ts @@ -0,0 +1,2 @@ +export * from './search_models'; +export * from './value_picker_models'; diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/search_models.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/search_models.ts new file mode 100644 index 0000000000..5f4e486abf --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/search_models.ts @@ -0,0 +1,114 @@ +import { + Cell, + Column, + ComplexMatch, + EnumPicker, + Filter, + FleetColumnConfig, + FleetFilterChipMetadata, + FleetLandingConfig, + NamedPairPicker, + Row, + TextInputPicker, + TextSegment, + TimeRangePicker, + TjsDefaultChip, + TjsFilter, + TjsPromotedKey, +} from '../../../core/models/search'; +import {AdvancedMatchMode} from './value_picker_models'; + +/** Supported entity category names for search views. */ +export type EntityType = 'devices' | 'hosts' | 'tests' | 'jobs' | 'sessions'; + +/** Structured flat search results containing table columns, rows, and pagination information. */ +export interface FlatSearchResults { + columns?: Column[]; + rows?: Array; + nextPageToken?: string; + prevPageToken?: string; + total?: number; + rangeStart?: number; + rangeEnd?: number; +} + +/** Unified metadata descriptor for search filter keys. */ +export interface FilterKeyMetadata extends Partial { + key?: string; + timeRange?: TimeRangePicker | boolean; + enumPicker?: EnumPicker | boolean; + namedPair?: + | NamedPairPicker + | {namePlaceholder?: string; valuePlaceholder?: string}; + textInput?: TextInputPicker | {placeholder?: string}; +} + +/** Base chip metadata and matching properties. */ +export interface ChipMetadata { + metadata?: FleetFilterChipMetadata; + rawValues?: string[]; + negated?: boolean; + complex?: ComplexMatch; +} + +/** Normalized descriptor extracted from a ComplexMatch structure matching ADV_MODES_LIST. */ +export interface ComplexMatchInfo { + mode: AdvancedMatchMode; + values: string[]; + isNegated: boolean; +} + +/** Structure representing an active or staged filter chip. */ +export interface FilterChip extends ChipMetadata { + key?: string; + pillKey: string; + pillCondition: string; + isGroupBy?: boolean; +} + +/** Deserialized/parsed result of a URL query string filter param. */ +export interface ParsedQueryFilter extends ChipMetadata { + key: string; + filter: Filter; + tjsFilter: TjsFilter; + fallbackPillCondition: string; +} + +/** Suggestion item entry rendered in the search box suggestion popover. */ +export interface SearchBoxSuggestion { + label?: string; + mainText?: TextSegment[]; + count?: number; + countPrefix?: string; + countUnit?: string; + overMax?: boolean; + rawItem?: unknown; +} + +/** Promoted preset filter key item. */ +export interface PromotedFilterKeyItem { + key: string; + metadata?: FleetFilterChipMetadata; +} + +/** Promoted preset group-by key item. */ +export interface PromotedGroupByKeyItem { + key: string; + displayName: string; + groupCount?: number; +} + +/** Union type for search result rows supporting both structured Row and key-value Record structures. */ +export type SearchResultRow = Record< + string, + Cell | string | number | boolean | unknown +>; + +/** Configuration options for the search page. */ +export interface SearchPageConfig { + columns?: FleetColumnConfig; + landing?: FleetLandingConfig; + entityLabel?: string; + defaultChips?: TjsDefaultChip[]; + promotedKeys?: TjsPromotedKey[]; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/value_picker_models.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/value_picker_models.ts new file mode 100644 index 0000000000..d0d49b9f03 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/models/value_picker_models.ts @@ -0,0 +1,97 @@ +/** Represents a selectable item in the value picker. */ +export interface PickerValueItem { + value: string; + displayLabel: string; + filtered?: number; + total?: number; + isNoValue?: boolean; + disabled?: boolean; +} + +/** Supported advanced matching mode keys. */ +export type AdvancedMatchMode = + | 'prefix' + | 'substring' + | 'not_substring' + | 'regex' + | 'not_regex' + | 'exactly' + | 'at_least'; + +/** Payload emitted when applying value picker changes. */ +export interface ValuePickerApplyEvent { + selected: string[]; + negate: boolean; + isAdvanced: boolean; + advMode?: AdvancedMatchMode; + advText?: string; + advValues?: string[]; + rangeFrom?: string; + rangeTo?: string; + propName?: string; + textVal?: string; +} + +/** Configuration layout and feature toggle properties for ValuePicker. */ +export interface ValuePickerConfig { + key: string; + type: 'list' | 'range' | 'namedPair' | 'text'; + title?: string; + canUseAdvanced?: boolean; + isPlural?: boolean; + valuesType?: 'counted' | 'plain'; + needsName?: boolean; + namePlaceholder?: string; + valPlaceholder?: string; + showNegateToggle?: boolean; + showAdvancedMenu?: boolean; + showSearchInput?: boolean; + showRowActions?: boolean; +} + +/** Active reactive state values for ValuePicker. */ +export interface ValuePickerState { + loading: boolean; + values: PickerValueItem[]; + selectedValues: Set; + negated: boolean; + advanced: { + active: boolean; + mode: AdvancedMatchMode; + text: string; + values: string[]; + }; +} + +/** Default initial state for ValuePicker overlay. */ +export const INITIAL_VALUE_PICKER_STATE: ValuePickerState = Object.freeze({ + loading: false, + values: [], + selectedValues: new Set(), + negated: false, + advanced: { + active: false, + mode: 'prefix' as AdvancedMatchMode, + text: '', + values: [], + }, +}); + +/** List of configurations for advanced matching options. */ +export const ADV_MODES_LIST: Array<{ + id: AdvancedMatchMode; + label: string; + placeholder: string; +}> = [ + {id: 'prefix', label: 'Starts with', placeholder: 'Enter a prefix…'}, + {id: 'substring', label: 'Contains', placeholder: 'Enter text…'}, + {id: 'not_substring', label: 'Does not contain', placeholder: 'Enter text…'}, + {id: 'regex', label: 'Matches regex', placeholder: 'Enter a pattern…'}, + { + id: 'not_regex', + label: 'Does not match regex', + placeholder: 'Enter a pattern…', + }, + {id: 'exactly', label: 'Is exactly', placeholder: 'Add a value…'}, + {id: 'at_least', label: 'Is at least', placeholder: 'Add a value…'}, +]; diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.ng.html new file mode 100644 index 0000000000..55ddc574e5 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.ng.html @@ -0,0 +1,44 @@ +
+ + + @if (store.isLandingState()) { + @if (store.isConfigLoading() && !store.searchConfig()) { + +
+ +
+ } @else { + +
+ @if (store.searchConfig()?.landing?.tryCategories && store.searchConfig()!.landing!.tryCategories!.length > 0) { +
+

Try searching by

+ @for (cat of store.searchConfig()!.landing!.tryCategories!; track cat.label) { +
+ {{ cat.label }} +
+ @for (ex of cat.examples || []; track ex.text) { + + } +
+
+ } +
+ } + +
+ +
+
+ } + } @else { + + @if (!store.isTjs()) { + + } @else { + + } + } +
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.scss new file mode 100644 index 0000000000..30cefce35a --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.scss @@ -0,0 +1,211 @@ +@use '@angular/material' as mat; +@use '../../shared/styles/common'; + +:host { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + min-height: 0; + width: 100%; + box-sizing: border-box; + overflow: hidden; +} + +.search-page-container { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 100%; + height: 100%; + min-height: 0; + margin: 0 auto; + flex: 1; + box-sizing: border-box; + overflow: hidden; +} + +app-search-box { + position: relative; + z-index: 100; + display: block; +} + +.comp-area { + position: relative; + z-index: 10; + flex-shrink: 0; +} + +app-fleet-search-results, +app-tjs-search-results { + position: relative; + z-index: 1; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + + + +.rt-landing { + flex: 1; + overflow: auto; + width: 100%; + max-width: 960px; + padding: 8px 0 24px; + animation: fadeIn 0.2s cubic-bezier(0, 0, 0.2, 1); + + &.rt-landing-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + } +} + +.landing-spinner { + margin: auto; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.rt-land-section { + padding-bottom: 24px; + + & + .rt-land-section { + border-top: 1px solid #c4c7c5; + padding-top: 24px; + } + + &.rt-land-browse { + padding-bottom: 0; + } +} + +.rt-land-label { + margin: 0 0 12px; + padding-left: 16px; + font-size: 0.875rem; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.1px; + color: #444746; +} + +.rt-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.rt-list-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + height: 48px; + padding: 0 16px; + border-radius: 24px; + border: none; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 0.875rem; + color: #1f1f1f; + transition: background-color 0.15s; + text-align: left; + + &:hover { + background-color: rgba(29, 27, 32, 0.08); + } + + .rt-list-lead { + font-size: 20px; + color: #444746; + flex-shrink: 0; + } + + .rt-list-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .rt-list-trail { + flex-shrink: 0; + font-size: 0.75rem; + color: #444746; + + &.rt-list-age { + width: 56px; + text-align: right; + } + } +} + +.rt-try-cat { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + padding-left: 16px; +} + +.rt-try-cat-label { + flex-shrink: 0; + width: 88px; + font-size: 0.8125rem; + color: #444746; +} + +.rt-try-examples { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.rt-try-ex { + padding: 0; + border: none; + background: none; + cursor: pointer; + font-family: inherit; + font-size: 0.875rem; + color: #0b57d0; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } +} + +button.browse-all-btn { + margin-left: 16px; + border-radius: 20px; + padding: 0 24px; + height: 40px; + font-weight: 500; + letter-spacing: normal; + background-color: #c2e7ff; + color: #001d35; +} + + diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.ts new file mode 100644 index 0000000000..f3e9372bc1 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/search_page.ts @@ -0,0 +1,62 @@ +import {CommonModule} from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + inject, + Injector, + OnInit, + viewChild, +} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; + +import {LoadingService} from '../../shared/services/loading_service'; +import {SearchBox} from './components/search_box/search_box'; +import {FleetSearchResultsComponent} from './components/search_results/fleet_search_results/fleet_search_results'; +import {TjsSearchResultsComponent} from './components/search_results/tjs_search_results/tjs_search_results'; +import {SearchPageStore} from './services/search_page_store'; + +/** Search page containing search input, filters, presets, and result views. */ +@Component({ + selector: 'app-search-page', + standalone: true, + templateUrl: './search_page.ng.html', + styleUrl: './search_page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatButtonModule, + MatProgressSpinnerModule, + FleetSearchResultsComponent, + TjsSearchResultsComponent, + SearchBox, + ], +}) +export class SearchPage implements OnInit { + readonly store = inject(SearchPageStore); + private readonly loadingService = inject(LoadingService); + private readonly injector = inject(Injector); + + readonly searchBox = viewChild(SearchBox); + + ngOnInit() { + this.loadingService.hide(); + } + + fillSearch(text: string) { + this.store.searchQuery.set(text); + this.store.showSuggestions.set(true); + afterNextRender( + () => { + this.searchBox()?.focusInput(); + }, + {injector: this.injector}, + ); + } + + onBrowseAll() { + this.store.browseAll.set(true); + this.store.executeSearch(); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/fleet_search_store.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/fleet_search_store.ts new file mode 100644 index 0000000000..0b76319795 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/fleet_search_store.ts @@ -0,0 +1,949 @@ +import { + computed, + Injectable, + linkedSignal, + signal, +} from '@angular/core'; +import {rxResource, takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {Observable, of} from 'rxjs'; +import {catchError, map} from 'rxjs/operators'; + +import { + Column, + Filter, + Fleet, + FleetChipResolverRequest, + FleetFilterChipMetadata, + FleetFlatResults, + FleetPromotedGroupByKey, + FleetPromotedKeysRequest, + FleetSearchConfig, + FleetSearchConfigRequest, + FleetSearchRequest, + FleetSearchResults, + FleetSuggestion, + FleetSuggestionRequest, + FleetValueListRequest, + Row, + SearchEntity, +} from '../../../core/models/search'; +import { + EntityType, + FilterChip, + FilterKeyMetadata, + ParsedQueryFilter, + PickerValueItem, + PromotedFilterKeyItem, + PromotedGroupByKeyItem, + SearchBoxSuggestion, + ValuePickerApplyEvent, + ValuePickerConfig, + ValuePickerState, +} from '../models'; +import { + AdvancedMatchMode, + buildComplexMatchFromEvent, + buildFleetFilterFromChip, + buildFleetGroupSort, + buildSimpleFleetFilter, + extractAdvancedStateFromChip, + extractFilterChipFromFleetSuggestion, + formatPillConditionFromEvent, + isChipNegated, + isValuePickerSelectionEmpty, + mapToSearchBoxSuggestion, +} from '../utils'; +import {SearchPageStore} from './search_page_store'; + +/** + * State store managing search, grouping, pagination, and selection for Fleet entities (Devices & Hosts). + */ +@Injectable() +export class FleetSearchStore extends SearchPageStore { + // =========================================================================== + // 1. Dynamic UI Prompts & Suggestion Signals + // =========================================================================== + + /** Placeholder text for search box input based on current entity (devices vs hosts). */ + override readonly searchPlaceholder = computed(() => { + switch (this.entity()) { + case 'devices': + return 'Type "pixel 10", or "model is", or a device ID'; + case 'hosts': + return 'Type a host name, or "status is", or a host ID'; + default: + return 'Type search query…'; + } + }); + + /** Autocomplete suggestions transformed into SearchBoxSuggestion items. */ + override readonly suggestions = computed(() => { + return this.fleetSuggestions().map(mapToSearchBoxSuggestion); + }); + + /** Reactive resource fetching search bar autocomplete suggestions. */ + private readonly suggestionsResource = rxResource< + FleetSuggestion[], + | { + entity: EntityType; + input: string; + fleet: string; + activeFilters: Filter[]; + groupBy: string[]; + } + | undefined + >({ + params: () => { + const e = this.entity(); + if (e !== 'devices' && e !== 'hosts') return undefined; + const show = this.showSuggestions(); + if (!show) return undefined; + + return { + entity: e, + input: this.debouncedSearchQuery(), + fleet: this.fleet(), + activeFilters: this.getAllEffectiveFilters(), + groupBy: this.groupByKeys(), + }; + }, + stream: ({params: req}) => { + if (!req) return of([]); + const fleetReq: FleetSuggestionRequest = { + entity: this.getSearchEntityProto(req.entity), + input: req.input, + filters: req.activeFilters.length > 0 ? req.activeFilters : undefined, + groupBy: req.groupBy.length > 0 ? req.groupBy : undefined, + fleet: req.fleet === 'ats' ? Fleet.FLEET_ATS : Fleet.FLEET_SELF, + limit: 10, + }; + return this.searchService.getFleetSuggestions(fleetReq).pipe( + map((res) => res.items || []), + catchError(() => of([])), + ); + }, + }); + + /** Exposes current array of fleet suggestions. */ + readonly fleetSuggestions = computed( + () => this.suggestionsResource.value() || [], + ); + + // =========================================================================== + // 2. Page Configuration & Metadata Resources + // =========================================================================== + + /** Reactive resource fetching search configuration (defaults, landing settings, default columns). */ + private readonly searchConfigResource = rxResource< + FleetSearchConfig | null, + string | undefined + >({ + params: () => { + const e = this.entity(); + if (e !== 'devices' && e !== 'hosts') return undefined; + return `${e}:${this.fleet()}`; + }, + stream: ({params: key}) => { + if (!key) return of(null); + const [e, fleet] = key.split(':') as [EntityType, string]; + const fleetReq: FleetSearchConfigRequest = { + entity: this.getSearchEntityProto(e), + fleet: fleet === 'ats' ? Fleet.FLEET_ATS : Fleet.FLEET_SELF, + }; + return this.searchService + .getFleetSearchConfig(fleetReq) + .pipe(catchError(() => of(null))); + }, + }); + + /** Configuration for the current search page. */ + override readonly searchConfig = computed( + () => this.searchConfigResource.value() || null, + ); + + /** Whether initial search configuration is loading. */ + override readonly isConfigLoading = computed( + () => this.searchConfigResource.isLoading(), + ); + + /** Reactive resource fetching promoted filter keys and promoted group-by keys. */ + private readonly promotedKeysResource = rxResource< + { + filterKeys: PromotedFilterKeyItem[]; + groupByKeys: PromotedGroupByKeyItem[]; + }, + | { + entity: EntityType; + fleet: string; + groupBy: string[]; + filters: Filter[]; + } + | undefined + >({ + params: () => { + const e = this.entity(); + if (e !== 'devices' && e !== 'hosts') return undefined; + + // Wait for searchConfig to load default chips on clean page entry to prevent duplicate RPC calls + const queryParams = this.route.snapshot?.queryParams; + const hasUrlFilters = queryParams?.['f'] || queryParams?.['gb']; + if (this.isConfigLoading() && !hasUrlFilters && this.activeChips().length === 0) { + return undefined; + } + + return { + entity: e, + fleet: this.fleet(), + groupBy: this.groupByKeys(), + filters: this.getAllEffectiveFilters(), + }; + }, + stream: ({params: req}) => { + if (!req) return of({filterKeys: [], groupByKeys: []}); + + const fleetReq: FleetPromotedKeysRequest = { + entity: this.getSearchEntityProto(req.entity), + fleet: req.fleet === 'ats' ? Fleet.FLEET_ATS : Fleet.FLEET_SELF, + groupBy: req.groupBy.length > 0 ? req.groupBy : undefined, + filters: req.filters.length > 0 ? req.filters : undefined, + }; + return this.searchService.getFleetPromotedKeys(fleetReq).pipe( + map((res) => ({ + filterKeys: res.filterKeys || [], + groupByKeys: res.groupByKeys || [], + })), + catchError(() => of({filterKeys: [], groupByKeys: []})), + ); + }, + }); + + /** Promoted filter key preset items. */ + override readonly promotedFilterKeys = computed( + () => this.promotedKeysResource.value()?.filterKeys || [], + ); + + /** Promoted group-by key preset items. */ + override readonly promotedGroupByKeys = computed( + () => this.promotedKeysResource.value()?.groupByKeys || [], + ); + + /** Whether promoted keys are currently loading. */ + override readonly isPromotedKeysLoading = computed( + () => this.promotedKeysResource.isLoading(), + ); + + /** Dynamic dictionary map containing metadata for all known filter and group-by keys. */ + override readonly keyMetadataMap = computed>( + () => { + const nextMap = new Map(); + const res = this.promotedKeysResource.value(); + for (const pk of res?.filterKeys || []) { + if (pk.metadata) { + nextMap.set(pk.key, pk.metadata); + } + } + for (const gk of res?.groupByKeys || []) { + if (!nextMap.has(gk.key)) { + nextMap.set(gk.key, {keyDisplayName: gk.displayName}); + } + } + return nextMap; + }, + ); + + // =========================================================================== + // 3. Table Columns, Selection, & View Configuration + // =========================================================================== + + /** Set of currently selected item (device/host) IDs across search results. */ + readonly selectedItems = signal>(new Set()); + + /** Current column key used for table sorting. */ + readonly sortColumn = signal('id'); + + /** Sort order direction (true = ascending, false = descending). */ + readonly sortAsc = signal(true); + + /** Set of column keys currently requested/visible in the data table (linked to searchConfig defaults). */ + readonly visibleColumns = linkedSignal>({ + source: () => this.searchConfig(), + computation: (cfg) => { + const keys = cfg?.columns?.defaults?.map((c) => c.key) ?? []; + return new Set(keys); + }, + }); + + /** Available table column choices derived from search config or default fallback list. */ + readonly availableColumns = computed>( + () => { + const configCols = this.searchConfig()?.columns?.defaults; + if (configCols && configCols.length > 0) { + return configCols.map((c) => ({key: c.key, label: c.displayName})); + } + return [ + {key: 'id', label: 'Device ID / UUID'}, + {key: 'hostName', label: 'Host Name'}, + {key: 'status', label: 'Status'}, + {key: 'type', label: 'Device Type'}, + {key: 'owner', label: 'Owner'}, + {key: 'model', label: 'Model'}, + ]; + }, + ); + + /** Display columns to render in search results table. */ + override readonly displayColumns = computed(() => { + const cols = this.searchResource.value()?.flat?.columns; + if (cols && cols.length > 0) { + return cols; + } + const cfgCols = this.searchConfig()?.columns?.defaults; + if (cfgCols && cfgCols.length > 0) { + return cfgCols.map((c) => ({key: c.key, displayName: c.displayName})); + } + return []; + }); + + // =========================================================================== + // 4. Grouped View & Header Accordion States + // =========================================================================== + + /** Unique scope key reflecting the active query parameters for invalidating expanded group state. */ + private readonly searchScopeKey = computed(() => { + return `${this.entity()}:${this.currentUrlParamKey()}:${this.groupSort()}`; + }); + + /** Set of expanded group header IDs, automatically reset when query scope changes. */ + readonly openGroupIds = linkedSignal>({ + source: () => this.searchScopeKey(), + computation: () => new Set(), + }); + + /** Sorting mode for grouped view headers. */ + readonly groupSort = signal('count:desc'); + + /** Map holding pagination data and loading states for expanded group headers, automatically reset when query scope changes. */ + readonly expandedGroupPages = linkedSignal< + string, + Map + >({ + source: () => this.searchScopeKey(), + computation: () => new Map(), + }); + + /** Grouped results object from the primary search resource. */ + readonly groupedResults = computed(() => this.searchResource.value()?.grouped); + + /** Formats display label text for active group-by headers (e.g. "Model × Status"). */ + readonly groupKeysText = computed(() => { + const keys = this.searchResource.value()?.grouped?.groupByKeys || []; + return keys + .map((k: FleetPromotedGroupByKey) => k.displayName || k.key) + .join(' × '); + }); + + /** Dynamic options list for sorting group header cards based on entity and active group-by chips. */ + override readonly groupSortOptions = computed>(() => { + const entityLabel = this.entity() === 'hosts' ? 'hosts' : 'devices'; + const countOptions = [ + {value: 'count:desc', label: `Most ${entityLabel}`}, + {value: 'count:asc', label: `Fewest ${entityLabel}`}, + ]; + + const groupByChips = this.activeChips().filter((c) => c.isGroupBy); + if (groupByChips.length === 0) { + return [ + ...countOptions, + {value: 'name:asc', label: 'Name (A–Z)'}, + {value: 'name:desc', label: 'Name (Z–A)'}, + ]; + } + + const gbOptions: Array<{value: string; label: string}> = []; + for (const chip of groupByChips) { + const rawKey = chip.key ? chip.key.replace(/^group_by_/, '') : chip.pillKey; + const meta = this.keyMetadataMap().get(rawKey); + const displayName = chip.pillKey || meta?.keyDisplayName || rawKey; + + gbOptions.push({ + value: `gb_asc:${rawKey}`, + label: `${displayName} (A–Z)`, + }); + gbOptions.push({ + value: `gb_desc:${rawKey}`, + label: `${displayName} (Z–A)`, + }); + } + + return [...countOptions, ...gbOptions]; + }); + + /** Sets the sort order mode for group header cards and resets pagination. */ + setGroupSort(value: string) { + this.pageIndex.set(0); + this.pageToken.set(''); + this.groupSort.set(value); + } + + /** Toggles accordion open/collapsed state for a group and loads rows if newly opened and not cached. */ + toggleGroup(groupId: string) { + const current = new Set(this.openGroupIds()); + if (current.has(groupId)) { + current.delete(groupId); + } else { + current.add(groupId); + const existing = this.expandedGroupPages().get(groupId); + if (!existing?.data && !existing?.loading) { + this.onLoadGroupRows(groupId); + } + } + this.openGroupIds.set(current); + } + + /** Loads flat rows for a specific expanded group header. */ + onLoadGroupRows(groupId: string, pageToken?: string) { + const e = this.entity(); + if (e !== 'devices' && e !== 'hosts') return; + + const filters = this.getAllEffectiveFilters(); + + const req: FleetSearchRequest = { + entity: this.getSearchEntityProto(e), + fleet: this.fleet() === 'ats' ? Fleet.FLEET_ATS : Fleet.FLEET_SELF, + filters: filters.length > 0 ? filters : undefined, + groupExpand: { + groupId, + columns: Array.from(this.visibleColumns()), + pageToken, + }, + }; + + this.updateGroupExpandState(groupId, {loading: true}); + + this.searchService + .searchFleet(req) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (res) => { + this.updateGroupExpandState(groupId, { + loading: false, + data: res.flat, + }); + }, + error: (err) => { + this.updateGroupExpandState(groupId, { + loading: false, + error: err?.message || 'Failed to load group rows.', + }); + }, + }); + } + + /** Helper to update state for an expanded group in expandedGroupPages map. */ + private updateGroupExpandState( + groupId: string, + patch: {loading?: boolean; data?: FleetFlatResults; error?: string}, + ) { + this.expandedGroupPages.update((map) => { + const next = new Map(map); + const existing = next.get(groupId); + next.set(groupId, {...existing, ...patch}); + return next; + }); + } + + // =========================================================================== + // 5. ValuePicker Popover Async Options Resource + // =========================================================================== + + /** Reactive resource fetching value list items for the active Popover ValuePicker. */ + private readonly valueListResource = rxResource< + PickerValueItem[], + | { + entity: EntityType; + key: string; + activeFilters: Filter[]; + fleet: string; + } + | undefined + >({ + params: () => { + const e = this.entity(); + if (e !== 'devices' && e !== 'hosts') return undefined; + const key = this.pickerKey(); + const show = this.showValuePicker(); + if (!show || !key) return undefined; + + return { + entity: e, + key, + activeFilters: this.getAllEffectiveFilters(), + fleet: this.fleet(), + }; + }, + stream: ({params: req}) => { + if (!req) return of([]); + const fleetReq: FleetValueListRequest = { + entity: this.getSearchEntityProto(req.entity), + key: req.key, + filters: req.activeFilters.length > 0 ? req.activeFilters : undefined, + fleet: req.fleet === 'ats' ? Fleet.FLEET_ATS : Fleet.FLEET_SELF, + }; + return this.searchService.getFleetValueList(fleetReq).pipe( + map((res) => { + if (res.counted) { + const list: PickerValueItem[] = (res.counted.values || []).map( + (v) => ({ + value: v.value, + displayLabel: v.displayLabel || v.value, + filtered: v.filtered, + total: v.total, + }), + ); + if (res.counted.noValueEntry) { + list.push({ + value: '', + displayLabel: '(no value)', + filtered: res.counted.noValueEntry.filtered, + total: res.counted.noValueEntry.total, + isNoValue: true, + }); + } + return list; + } + + if (res.plain) { + const list: PickerValueItem[] = (res.plain.values || []).map( + (v) => ({ + value: v.value, + displayLabel: v.displayLabel || v.value, + }), + ); + if (res.plain.noValueEntry) { + list.push({ + value: '', + displayLabel: '(no value)', + isNoValue: true, + }); + } + return list; + } + + return []; + }), + catchError(() => of([])), + ); + }, + }); + + /** Loading state indicator for the async Popover ValuePicker options. */ + override readonly isPickerLoading = computed( + () => this.valueListResource.isLoading(), + ); + + /** Async options list delivered to the Popover ValuePicker. */ + override readonly asyncPickerValues = computed( + () => this.valueListResource.value() || [], + ); + + // =========================================================================== + // 6. Primary Search Resource & Execution + // =========================================================================== + + /** Primary reactive resource fetching flat or grouped fleet search results. */ + readonly searchResource = rxResource< + FleetSearchResults | null, + FleetSearchRequest | undefined + >({ + params: () => { + const e = this.entity(); + if (e !== 'devices' && e !== 'hosts') return undefined; + if (this.isLandingState()) return undefined; + + const fleet = this.fleet(); + const pageSize = this.pageSize(); + const pageToken = this.pageToken(); + + const filters = this.getAllEffectiveFilters(); + const groupByKeys = this.groupByKeys(); + + const req: FleetSearchRequest = { + entity: this.getSearchEntityProto(e), + fleet: fleet === 'ats' ? Fleet.FLEET_ATS : Fleet.FLEET_SELF, + filters: filters.length > 0 ? filters : undefined, + }; + + if (groupByKeys.length > 0) { + const groupSort = this.groupSort(); + req.groupHeader = { + groupBy: groupByKeys, + sort: buildFleetGroupSort(groupSort, groupByKeys), + page: { + pageSize, + pageToken: pageToken || undefined, + }, + }; + } else { + const visibleColumns = Array.from(this.visibleColumns()); + const sortColumn = this.sortColumn(); + const sortAsc = this.sortAsc(); + req.flat = { + columns: visibleColumns, + sort: {key: sortColumn, ascending: sortAsc}, + page: { + pageSize, + pageToken: pageToken || undefined, + }, + }; + } + return req; + }, + stream: ({params: req}) => { + if (!req) { + return of(null); + } + return this.searchService + .searchFleet(req) + .pipe(catchError(() => of(null))); + }, + }); + + /** Exposes loading status of primary search resource. */ + override readonly isLoading = computed(() => this.searchResource.isLoading()); + + /** Rows array to render in search results table. */ + override readonly rows = computed(() => { + return (this.searchResource.value()?.flat?.rows as Row[]) || []; + }); + + /** Determines whether a next page is available based on API nextPageToken. */ + override readonly hasNextPage = computed(() => { + const res = this.searchResource.value(); + const isGrouped = this.groupByKeys().length > 0; + const token = isGrouped + ? res?.grouped?.nextPageToken + : res?.flat?.nextPageToken; + return !!token; + }); + + /** Total matching count across flat or grouped search. */ + readonly effectiveTotalCount = computed(() => { + return ( + (this.groupByKeys().length > 0 + ? this.groupedResults()?.totalItems + : this.searchResource.value()?.flat?.total) || 0 + ); + }); + + /** Start 1-based row range returned directly from API. */ + readonly effectiveRangeStart = computed(() => { + const res = this.searchResource.value(); + return ( + (this.groupByKeys().length > 0 + ? res?.grouped?.rangeStart + : res?.flat?.rangeStart) || 0 + ); + }); + + /** End 1-based row range returned directly from API. */ + readonly effectiveRangeEnd = computed(() => { + const res = this.searchResource.value(); + return ( + (this.groupByKeys().length > 0 + ? res?.grouped?.rangeEnd + : res?.flat?.rangeEnd) || 0 + ); + }); + + /** Combines active filter chips into backend Filter array. */ + private getAllEffectiveFilters(): Filter[] { + return this.activeChips() + .filter((c) => !c.isGroupBy) + .map((c) => buildFleetFilterFromChip(c)); + } + + /** Reloads the primary search resource and resets page token and group expansions. */ + override executeSearch() { + this.pageIndex.set(0); + this.pageToken.set(''); + this.openGroupIds.set(new Set()); + this.expandedGroupPages.set(new Map()); + this.searchResource.reload(); + } + + /** Maps string entity type to SearchEntity protobuf enum value. */ + private getSearchEntityProto(entity: EntityType): SearchEntity { + return entity === 'hosts' + ? SearchEntity.SEARCH_ENTITY_HOST + : SearchEntity.SEARCH_ENTITY_DEVICE; + } + + // =========================================================================== + // 7. Pagination Controls + // =========================================================================== + + /** Helper to extract next/prev page token from search results. */ + private getPaginationToken(type: 'next' | 'prev'): string | undefined { + const res = this.searchResource.value(); + const isGrouped = this.groupByKeys().length > 0; + if (type === 'next') { + return isGrouped ? res?.grouped?.nextPageToken : res?.flat?.nextPageToken; + } + return isGrouped ? res?.grouped?.prevPageToken : res?.flat?.prevPageToken; + } + + /** Navigates to previous page of search results. */ + override prevPage() { + const token = this.getPaginationToken('prev'); + if (token) { + this.pageToken.set(token); + this.pageIndex.update((p) => Math.max(0, p - 1)); + } + } + + /** Navigates to next page of search results. */ + override nextPage() { + const token = this.getPaginationToken('next'); + if (token) { + this.pageToken.set(token); + this.pageIndex.update((p) => p + 1); + } + } + + // =========================================================================== + // 8. URL Resolution & ValuePicker Application + // =========================================================================== + + /** Resolves parsed URL query filters and group-by keys into FilterChip list via Fleet RPC. */ + protected override resolveChipsFromBackend( + parsedFilters: ParsedQueryFilter[], + groupByKeys: string[], + ): Observable { + const req: FleetChipResolverRequest = { + filters: + parsedFilters.length > 0 + ? parsedFilters.map((pf) => pf.filter) + : undefined, + groupByKeys: groupByKeys.length > 0 ? groupByKeys : undefined, + }; + + return this.searchService.resolveFleetChips(req).pipe( + map((res) => { + const updatedChips: FilterChip[] = []; + if (res.filterChips && res.filterChips.length > 0) { + res.filterChips.forEach((fc, idx) => { + const pf = parsedFilters[idx]; + updatedChips.push({ + key: pf?.key, + pillKey: fc.pillKey, + pillCondition: fc.pillCondition, + metadata: fc.metadata || pf?.metadata, + rawValues: pf?.rawValues, + negated: pf?.negated, + complex: pf?.complex, + }); + }); + } + + groupByKeys.forEach((gb, idx) => { + const gbc = res.groupByChips?.[idx]; + updatedChips.push( + this.buildGroupByChip(gb, gbc?.displayName || gbc?.pillKey), + ); + }); + + return updatedChips; + }), + ); + } + + /** Constructs Fleet-specific ValuePicker configuration. */ + protected override buildPickerConfig( + key: string, + title?: string, + metadata?: unknown, + ): ValuePickerConfig { + const meta = + (metadata as FleetFilterChipMetadata) || + this.keyMetadataMap().get(key); + const displayTitle = meta?.keyDisplayName || title || key; + + return { + key, + type: 'list', + title: displayTitle, + needsName: false, + canUseAdvanced: !!meta?.canUseAdvanced, + isPlural: !!meta?.isPlural, + showNegateToggle: true, + showAdvancedMenu: !!meta?.canUseAdvanced, + showSearchInput: true, + showRowActions: true, + namePlaceholder: 'Name', + valPlaceholder: 'Value', + }; + } + + /** Constructs initial ValuePicker interaction state from an active filter chip. */ + protected override buildInitialPickerState( + key: string, + activeChip?: FilterChip, + metadata?: unknown, + ): ValuePickerState { + let isAdv = false; + let advMode: AdvancedMatchMode = 'prefix'; + let advText = ''; + let advValues: string[] = []; + let isNegated = false; + let selectedVals = new Set(); + + if (activeChip) { + const state = extractAdvancedStateFromChip(activeChip); + isAdv = state.isAdv; + if (isAdv) { + advMode = state.advMode; + advText = state.advText; + advValues = state.advValues; + } + isNegated = isChipNegated(activeChip); + + if (!isAdv) { + if (activeChip.rawValues && activeChip.rawValues.length > 0) { + selectedVals = new Set(activeChip.rawValues); + } else if (activeChip.pillCondition) { + const existingVals = activeChip.pillCondition + .replace( + /^(Starts with|Contains|Does not contain|Matches regex|Does not match regex|Is exactly|Is at least)\s*"?([^"]*)"?$/i, + '$2', + ) + .split(',') + .map((s: string) => s.trim()) + .map((s: string) => (s === '(no value)' ? '' : s)) + .filter(Boolean); + selectedVals = new Set(existingVals); + } + } + } + + return { + loading: false, + values: [], + selectedValues: selectedVals, + negated: isNegated, + advanced: { + active: isAdv, + mode: advMode, + text: advText, + values: advValues, + }, + }; + } + + /** Applies selections or advanced matching input from Popover ValuePicker. */ + override applyValuePicker(event: ValuePickerApplyEvent) { + const key = this.pickerKey(); + const title = this.pickerTitle(); + const meta = key ? this.keyMetadataMap().get(key) : undefined; + const displayTitle = meta?.keyDisplayName || title; + this.closeValuePicker(); + + if (isValuePickerSelectionEmpty(event)) { + this.removeChipForKey(key); + return; + } + + const fallbackPillCondition = formatPillConditionFromEvent(event); + + const rawValues = event.isAdvanced + ? event.advValues && event.advValues.length > 0 + ? event.advValues + : event.advText + ? [event.advText.trim()] + : undefined + : event.selected; + + const filter: Filter = event.isAdvanced + ? {key, complex: buildComplexMatchFromEvent(event)} + : buildSimpleFleetFilter(key, event.selected || [], event.negate); + const complex = filter.complex; + + const req: FleetChipResolverRequest = { + filters: [filter], + }; + + const applyFallback = () => { + this.addFilterChip({ + key, + pillKey: displayTitle, + pillCondition: fallbackPillCondition, + isGroupBy: false, + metadata: meta, + rawValues, + negated: event.isAdvanced ? false : event.negate, + complex: event.isAdvanced ? complex : undefined, + }); + }; + + this.searchService + .resolveFleetChips(req) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (res) => { + if (res.filterChips && res.filterChips.length > 0) { + for (const fc of res.filterChips) { + this.addFilterChip({ + key, + pillKey: fc.pillKey, + pillCondition: fc.pillCondition, + isGroupBy: false, + metadata: fc.metadata || meta, + rawValues, + negated: event.isAdvanced ? false : event.negate, + complex: event.isAdvanced ? complex : undefined, + }); + } + } else { + applyFallback(); + } + }, + error: applyFallback, + }); + } + + // =========================================================================== + // 9. Suggestion Selection & State Reset + // =========================================================================== + + /** Directly resolves and applies fleet suggestion filters or group-by selections to store. */ + override selectSuggestion(item: SearchBoxSuggestion) { + const raw = item.rawItem as FleetSuggestion; + if (!raw) return; + + this.showSuggestions.set(false); + this.searchQuery.set(''); + + this.applyFleetSuggestionDirectly(raw); + } + + /** Helper to directly apply a FleetSuggestion locally without redundant backend resolution. */ + private applyFleetSuggestionDirectly(item: FleetSuggestion) { + const chip = extractFilterChipFromFleetSuggestion(item); + if (chip) { + this.addFilterChip(chip); + } + if (item.addGroupBy) { + const actionKey = item.addGroupBy.key; + const pillTitle = item.addGroupBy.pillKey || item.addGroupBy.key; + this.addFilterChip(this.buildGroupByChip(actionKey, pillTitle)); + } + } + + /** Resets search state including group view expand states and selected items. */ + override resetSearchState(updateUrl = true) { + super.resetSearchState(updateUrl); + + this.openGroupIds.set(new Set()); + this.expandedGroupPages.set(new Map()); + this.selectedItems.set(new Set()); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/search_page_store.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/search_page_store.ts new file mode 100644 index 0000000000..2c2712a4b6 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/search_page_store.ts @@ -0,0 +1,812 @@ +import {CdkOverlayOrigin} from '@angular/cdk/overlay'; +import { + computed, + DestroyRef, + inject, + Injectable, + linkedSignal, + Signal, + signal, +} from '@angular/core'; +import {takeUntilDestroyed, toObservable, toSignal} from '@angular/core/rxjs-interop'; +import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; +import {Observable, of} from 'rxjs'; +import {catchError, debounceTime, distinctUntilChanged, filter, map} from 'rxjs/operators'; + +import {Column, Row} from '../../../core/models/search'; +import {SEARCH_SERVICE} from '../../../core/services/search/search_service'; +import { + EntityType, + FilterChip, + FilterKeyMetadata, + INITIAL_VALUE_PICKER_STATE, + ParsedQueryFilter, + PickerValueItem, + PromotedFilterKeyItem, + PromotedGroupByKeyItem, + SearchBoxSuggestion, + SearchPageConfig, + ValuePickerApplyEvent, + ValuePickerConfig, + ValuePickerState, +} from '../models'; +import { + buildUrlParamKey, + getQueryParamAsArray, + isSameFilterChip, + normalizeKey, + parseQueryFilterParam, + serializeFilterChip, +} from '../utils'; + +/** Resolves the active EntityType from current router URL or ActivatedRoute hierarchy. */ +function resolveInitialEntity(route: ActivatedRoute, router: Router): EntityType { + const locationUrl = + typeof window !== 'undefined' && window.location + ? window.location.pathname || window.location.href + : ''; + const url = router.url || locationUrl || ''; + if (url.includes('/hosts')) return 'hosts'; + if (url.includes('/devices')) return 'devices'; + if (url.includes('/tests')) return 'tests'; + if (url.includes('/jobs')) return 'jobs'; + if (url.includes('/sessions')) return 'sessions'; + + if (route.snapshot) { + for (const r of route.snapshot.pathFromRoot) { + if (r.data?.['entity']) { + return r.data['entity'] as EntityType; + } + } + } + if (route.snapshot?.data?.['entity']) { + return route.snapshot.data['entity'] as EntityType; + } + + return 'devices'; +} +function resolveInitialChips(route: ActivatedRoute): FilterChip[] { + const params = route.snapshot?.queryParams; + if (!params) return []; + + const fParams = getQueryParamAsArray(params['f']); + const gbParam = params['gb'] || ''; + const gbKeys = gbParam.split(',').filter(Boolean); + + if (fParams.length === 0 && gbKeys.length === 0) { + return []; + } + + const parsedFilters = fParams + .map(parseQueryFilterParam) + .filter(Boolean) as ParsedQueryFilter[]; + + return [ + ...parsedFilters.map((pf) => ({ + key: pf.key, + pillKey: pf.key, + pillCondition: pf.fallbackPillCondition, + rawValues: pf.rawValues, + negated: pf.negated, + complex: pf.complex, + })), + ...gbKeys.map((gb: string) => ({ + key: 'group_by_' + gb, + pillKey: gb, + pillCondition: gb, + isGroupBy: true, + })), + ]; +} + +/** Resolves initial fleet operational scope from ActivatedRoute snapshot query params. */ +function resolveInitialFleet(route: ActivatedRoute): 'internal' | 'ats' { + const fleetParam = route.snapshot?.queryParams?.['fleet']; + return fleetParam === 'ats' ? 'ats' : 'internal'; +} + +/** + * Abstract base store defining shared search state, reactive signals, and actions. + * + * Implements the Template Method pattern to provide domain-agnostic search bar + * interactions, filter chip state machine, URL resolution, and pagination + * while delegating domain-specific RPC and rendering details to subclasses. + */ +@Injectable() +export abstract class SearchPageStore { + // =========================================================================== + // 1. Injected Dependencies + // =========================================================================== + + /** Backend search service handling search queries and filter chip resolution. */ + protected readonly searchService = inject(SEARCH_SERVICE); + + /** Component/Store lifecycle destruction reference for cleaning up streams. */ + protected readonly destroyRef = inject(DestroyRef); + + /** Active route reference for reading and reacting to query parameters. */ + protected readonly route = inject(ActivatedRoute); + + /** Router service for navigating and syncing filter states to the browser URL. */ + protected readonly router = inject(Router); + + // =========================================================================== + // 2. Core Domain & Query Signals + // =========================================================================== + + /** Current active search entity type (e.g. 'devices', 'hosts', 'tests', 'jobs', 'sessions'). */ + readonly entity = signal( + resolveInitialEntity(this.route, this.router), + ); + + /** Fleet operational scope ('internal' vs 'ats'). */ + readonly fleet = signal<'internal' | 'ats'>( + resolveInitialFleet(this.route), + ); + + /** Raw text input query string entered by the user in the search box. */ + readonly searchQuery = signal(''); + + /** Debounced search query Signal (300ms debounce + distinctUntilChanged). */ + protected readonly debouncedSearchQuery = toSignal( + toObservable(this.searchQuery).pipe( + debounceTime(300), + distinctUntilChanged(), + ), + {initialValue: ''}, + ); + + /** Active list of filter and group-by chips applied to the current search. */ + readonly activeChips = signal( + resolveInitialChips(this.route), + ); + + /** Indicates whether the user is browsing all records with no filters (Fleet only). */ + readonly browseAll = signal(false); + + /** Controls visibility of the search box suggestions popover dropdown. */ + readonly showSuggestions = signal(false); + + // =========================================================================== + // 3. ValuePicker Overlay State Signals + // =========================================================================== + + /** Controls visibility of the ValuePicker CDK Overlay popover. */ + readonly showValuePicker = signal(false); + + /** Dynamic DOM anchor or CDK Overlay Origin where ValuePicker is currently positioned. */ + readonly pickerAnchor = signal(null); + + /** Static layout and feature configuration for the currently open ValuePicker. */ + readonly pickerConfig = signal(null); + + /** User interaction state within the ValuePicker (selected values, negation, advanced mode). */ + readonly pickerState = signal(INITIAL_VALUE_PICKER_STATE); + + /** Target filter key currently being edited in the ValuePicker. */ + readonly pickerKey = computed(() => this.pickerConfig()?.key || ''); + + /** Display title for the currently open ValuePicker. */ + readonly pickerTitle = computed(() => this.pickerConfig()?.title || ''); + + /** Indicates whether enum options are being asynchronously loaded for the ValuePicker. */ + readonly isPickerLoading: Signal = signal(false); + + /** Asynchronously fetched enum/value options for the ValuePicker. */ + readonly asyncPickerValues: Signal = signal([]); + + /** Effective merged ValuePicker state combining user inputs and async fetched values/loading. */ + readonly effectivePickerState = computed(() => { + const raw = this.pickerState(); + const asyncLoading = this.isPickerLoading(); + const asyncVals = this.asyncPickerValues(); + return { + ...raw, + loading: raw.loading || asyncLoading, + values: asyncVals.length > 0 ? asyncVals : raw.values, + }; + }); + + // =========================================================================== + // 4. Pagination & Table Display State Signals + // =========================================================================== + + /** Current zero-based page index. Automatically resets to 0 when chips or query change. */ + readonly pageIndex = linkedSignal({ + source: () => ({chips: this.activeChips(), query: this.searchQuery()}), + computation: () => 0, + }); + + /** Number of rows displayed per page. */ + readonly pageSize = signal(25); + + /** Opaque cursor token for backend pagination. Resets when chips or query change. */ + readonly pageToken = linkedSignal({ + source: () => ({chips: this.activeChips(), query: this.searchQuery()}), + computation: () => '', + }); + + /** Table row display density ('compact', 'default', or 'comfortable'). */ + readonly density = signal<'compact' | 'default' | 'comfortable'>('default'); + + // =========================================================================== + // 5. Derived Computed Signals + // =========================================================================== + + /** Whether the current entity belongs to the Test/Job/Session domain. */ + readonly isTjs = computed(() => { + const e = this.entity(); + return e === 'tests' || e === 'jobs' || e === 'sessions'; + }); + + /** Determines whether the Fleet scope switcher (Internal vs ATS) should be displayed. */ + readonly showScopeSwitcher = computed(() => !this.isTjs()); + + /** Placeholder text for the main search input box. */ + readonly searchPlaceholder = computed(() => `Search ${this.entity()}...`); + + /** Whether the clear button ('X') in the search bar should be displayed. */ + readonly showSearchClear = computed( + () => this.searchQuery().trim().length > 0 || this.activeChips().length > 0, + ); + + /** Whether the search view is currently in its initial landing / empty guide state. */ + readonly isLandingState = computed(() => { + if (this.isTjs()) return false; + return this.activeChips().length === 0 && !this.browseAll(); + }); + + /** List of active group-by field keys extracted from `activeChips`. */ + readonly groupByKeys = computed(() => + this.activeChips() + .filter((c) => c.isGroupBy) + .map((c) => (c.key ? c.key.replace(/^group_by_/, '') : c.pillKey)), + ); + + /** Serialized active filter parameter strings formatted for URL query. */ + readonly serializedActiveFilters = computed(() => + this.activeChips() + .filter((c) => !c.isGroupBy) + .map(serializeFilterChip) + .filter(Boolean), + ); + + /** Serialized active group-by key strings formatted for URL query. */ + readonly serializedActiveGroupBys = computed(() => + this.activeChips() + .filter((c) => c.isGroupBy) + .map((c) => c.key?.replace(/^group_by_/, '') || c.pillKey) + .filter(Boolean), + ); + + /** Canonical URL query parameter key computed from current active state. */ + readonly currentUrlParamKey = computed(() => + buildUrlParamKey( + this.serializedActiveFilters(), + this.serializedActiveGroupBys(), + this.fleet(), + ), + ); + + // =========================================================================== + // 6. Abstract Domain Interface (Template Method Hooks) + // =========================================================================== + + /** Domain-specific autocomplete and recommendation suggestions for the search box. */ + abstract readonly suggestions: Signal; + + /** Loading state for primary search result data fetching. */ + abstract readonly isLoading: Signal; + + /** Initial page configuration including metadata, default chips, and column definitions. */ + abstract readonly searchConfig: Signal; + + /** Whether initial search configuration is loading. */ + readonly isConfigLoading: Signal = signal(false); + + /** Promoted / recommended filter keys shown as quick-action presets. */ + abstract readonly promotedFilterKeys: Signal; + + /** Promoted / recommended group-by keys shown as quick-action presets. */ + readonly promotedGroupByKeys: Signal = signal([]); + + /** Options list for sorting group header cards in grouped mode. */ + readonly groupSortOptions: Signal> = signal([]); + + /** Whether auxiliary promoted keys are currently loading. */ + readonly isPromotedKeysLoading: Signal = signal(false); + + /** Map of filter key metadata for display names, types, and capability flags. */ + abstract readonly keyMetadataMap: Signal>; + + /** Visible column definitions for the search results table. */ + abstract readonly displayColumns: Signal; + + /** Result rows for the current page of search results. */ + abstract readonly rows: Signal; + + /** Whether a subsequent page of search results is available. */ + abstract readonly hasNextPage: Signal; + + /** Triggers execution of the primary domain search query. */ + abstract executeSearch(): void; + + /** Applies selections from the ValuePicker to create or update filter chips. */ + abstract applyValuePicker(event: ValuePickerApplyEvent): void; + + /** Navigates to the previous page of search results. */ + abstract prevPage(): void; + + /** Navigates to the next page of search results. */ + abstract nextPage(): void; + + /** Handles user selection of an autocomplete suggestion from the search box popover. */ + abstract selectSuggestion(item: SearchBoxSuggestion): void; + + /** Subclass hook to resolve chips from domain-specific backend RPC. */ + protected abstract resolveChipsFromBackend( + parsedFilters: ParsedQueryFilter[], + groupByKeys: string[], + ): Observable; + + /** Subclass hook to construct ValuePicker configuration for a filter key. */ + protected abstract buildPickerConfig( + key: string, + title?: string, + metadata?: unknown, + ): ValuePickerConfig; + + /** Subclass hook to construct initial ValuePicker state for a filter key. */ + protected abstract buildInitialPickerState( + key: string, + activeChip?: FilterChip, + metadata?: unknown, + ): ValuePickerState; + + /** Subclass hook to provide default filter chips on initial state / reset. Defaults to empty array. */ + getDefaultChips(): FilterChip[] { + return []; + } + + // =========================================================================== + // 7. URL Serialization & Deserialization Methods + // =========================================================================== + + /** Resolves parsed URL query filters and group-by keys into FilterChip list. */ + resolveUrlFilters( + parsedFilters: ParsedQueryFilter[], + groupByKeys: string[] = [], + ): Observable { + if (parsedFilters.length === 0 && groupByKeys.length === 0) { + return of([]); + } + + return this.resolveChipsFromBackend(parsedFilters, groupByKeys).pipe( + map((resolvedChips) => { + if (resolvedChips.length > 0) { + return resolvedChips; + } + return this.buildFallbackChips(parsedFilters, groupByKeys); + }), + catchError(() => of(this.buildFallbackChips(parsedFilters, groupByKeys))), + ); + } + + /** Constructs a fallback FilterChip from a parsed URL query filter using local metadata. */ + buildFallbackChip(pf: ParsedQueryFilter): FilterChip { + const meta = this.keyMetadataMap().get(pf.key); + return { + key: pf.key, + pillKey: meta?.keyDisplayName || pf.key, + pillCondition: pf.fallbackPillCondition, + rawValues: pf.rawValues, + metadata: meta, + negated: pf.negated, + complex: pf.complex, + }; + } + + /** Batch constructs fallback FilterChips for parsed query filters and group-by keys. */ + buildFallbackChips( + parsedFilters: ParsedQueryFilter[], + groupByKeys: string[] = [], + ): FilterChip[] { + return [ + ...parsedFilters.map((pf) => this.buildFallbackChip(pf)), + ...groupByKeys.map((gb) => this.buildGroupByChip(gb)), + ]; + } + + /** Constructs a specialized group-by FilterChip. */ + buildGroupByChip(gbKey: string, displayName?: string): FilterChip { + const meta = this.keyMetadataMap().get(gbKey); + const display = displayName || meta?.keyDisplayName || gbKey; + return { + key: 'group_by_' + gbKey, + pillKey: display, + pillCondition: display, + isGroupBy: true, + }; + } + + /** Tracks the last URL query parameter key that was successfully synced or processed. */ + private lastSyncedUrlKey: string | null = null; + + constructor() { + // 0. Sync entity type immediately on NavigationEnd to prevent stale route entity loading + this.router.events + .pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + const targetEntity = resolveInitialEntity(this.route, this.router); + if (targetEntity !== this.entity()) { + this.entity.set(targetEntity); + this.resetSearchState(false); + } + }); + + // 1. Sync entity type when route data changes + this.route.data + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((data) => { + const newEntity = data?.['entity'] as EntityType | undefined; + if (newEntity && newEntity !== this.entity()) { + this.entity.set(newEntity); + this.resetSearchState(false); + } + }); + + // 2. Initial resolution of URL query filters on page load / refresh + const initialSnapshotParams = this.route.snapshot?.queryParams; + if (initialSnapshotParams) { + const fParams = getQueryParamAsArray(initialSnapshotParams['f']); + const gbParam = initialSnapshotParams['gb'] || ''; + const gbKeys = gbParam.split(',').filter(Boolean); + const fleetParam = (initialSnapshotParams['fleet'] || 'internal') as + | 'internal' + | 'ats'; + + if (fParams.length > 0 || gbKeys.length > 0) { + const parsedFilters = fParams + .map(parseQueryFilterParam) + .filter(Boolean) as ParsedQueryFilter[]; + + const initialKey = buildUrlParamKey(fParams, gbKeys, fleetParam); + this.lastSyncedUrlKey = initialKey; + + this.resolveUrlFilters(parsedFilters, gbKeys) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (updatedChips) => { + if (updatedChips && updatedChips.length > 0) { + this.activeChips.set(updatedChips); + } + }, + }); + } + } + + // 3. Sync URL query parameters -> Active Chips & Fleet state + this.route.queryParams + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((params) => { + if (!params) return; + + const targetEntity = + (this.route.snapshot?.data?.['entity'] as EntityType | undefined) || + resolveInitialEntity(this.route, this.router); + if (targetEntity && targetEntity !== this.entity()) { + this.entity.set(targetEntity); + } + + const fleetParam = (params['fleet'] || 'internal') as + | 'internal' + | 'ats'; + if (fleetParam !== this.fleet()) { + this.fleet.set(fleetParam); + } + + const fParams = getQueryParamAsArray(params['f']); + const gbParam = params['gb'] || ''; + const gbKeys = gbParam.split(',').filter(Boolean); + + const incomingKey = buildUrlParamKey(fParams, gbKeys, fleetParam); + if (incomingKey === this.lastSyncedUrlKey) { + return; + } + this.lastSyncedUrlKey = incomingKey; + + if (fParams.length === 0 && gbKeys.length === 0) { + if ( + this.browseAll() || + this.activeChips().length > 0 || + this.searchQuery().trim() + ) { + this.resetSearchState(false); + } + return; + } + + const parsedFilters = fParams + .map(parseQueryFilterParam) + .filter(Boolean) as ParsedQueryFilter[]; + + // Immediately set synchronous fallback chips so UI is in sync without any latency/flicker + const fallbackChips = this.buildFallbackChips(parsedFilters, gbKeys); + this.activeChips.set(fallbackChips); + + // Asynchronously enhance chips with rich backend metadata + this.resolveUrlFilters(parsedFilters, gbKeys) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (updatedChips) => { + if (updatedChips && updatedChips.length > 0) { + this.activeChips.set(updatedChips); + } + }, + }); + }); + + // 3. Reset to clean initial state when Navigation Menu is clicked (same-URL or clean navigation without filter params) + this.router.events + .pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((event) => { + const activeEntity = resolveInitialEntity(this.route, this.router); + if (activeEntity !== this.entity()) { + return; + } + + const tree = this.router.parseUrl(event.urlAfterRedirects || event.url); + const f = tree.queryParams['f']; + const gb = tree.queryParams['gb']; + const fleetParam = (tree.queryParams['fleet'] || 'internal') as + | 'internal' + | 'ats'; + + if (!f && !gb) { + if ( + this.browseAll() || + this.activeChips().length > 0 || + this.searchQuery().trim() + ) { + this.resetSearchState(false); + } + if (this.fleet() !== fleetParam) { + this.fleet.set(fleetParam); + } + } + }); + } + + // =========================================================================== + // 8. Filter Chip Management & URL Synchronization + // =========================================================================== + + /** Synchronizes active filter chips and fleet state to the browser URL. */ + syncUrl( + chips: FilterChip[] = this.activeChips(), + fleet: 'internal' | 'ats' = this.fleet(), + replaceUrl = false, + ) { + + const filters = chips + .filter((c) => !c.isGroupBy) + .map(serializeFilterChip) + .filter(Boolean); + const groupBys = chips + .filter((c) => c.isGroupBy) + .map((c) => c.key?.replace(/^group_by_/, '') || c.pillKey) + .filter(Boolean); + + this.lastSyncedUrlKey = buildUrlParamKey(filters, groupBys, fleet); + + const queryParams: Record = { + 'f': filters.length > 0 ? filters : null, + 'gb': groupBys.length > 0 ? groupBys.join(',') : null, + 'fleet': fleet !== 'internal' ? fleet : null, + }; + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'merge', + replaceUrl, + }); + } + + /** Sets the active fleet operational scope and updates URL. */ + setFleet(fleet: 'internal' | 'ats') { + if (this.fleet() === fleet) return; + this.fleet.set(fleet); + this.resetSearchState(false); + this.syncUrl(this.getDefaultChips(), fleet); + } + + /** Adds a new filter chip or updates an existing one with matching key and type. */ + addFilterChip(chip: FilterChip) { + const newChip: FilterChip = { + ...chip, + pillCondition: chip.pillCondition || '', + }; + + const current = this.activeChips(); + const idx = current.findIndex((c) => isSameFilterChip(c, chip)); + + const nextChips = + idx !== -1 + ? current.map((c, i) => (i === idx ? newChip : c)) + : [...current, newChip]; + + this.activeChips.set(nextChips); + this.syncUrl(nextChips); + } + + /** Removes a specific filter chip and closes the ValuePicker if the active chip is removed. */ + removeFilterChip(chip: FilterChip) { + const nextChips = this.activeChips().filter( + (c) => !isSameFilterChip(c, chip), + ); + this.activeChips.set(nextChips); + if (nextChips.length === 0 && !this.searchQuery().trim()) { + this.browseAll.set(false); + } + + // Automatically close the ValuePicker if all chips are removed or the chip being edited was removed. + if (this.showValuePicker()) { + const currentPickerKey = this.pickerConfig()?.key; + if ( + nextChips.length === 0 || + !currentPickerKey || + isSameFilterChip(chip, { + key: currentPickerKey, + pillKey: currentPickerKey, + pillCondition: '', + }) + ) { + this.closeValuePicker(); + } + } + this.syncUrl(nextChips); + } + + /** Toggles a group-by key (adds it if not present, removes it if currently active). */ + toggleGroupBy(gbKey: string, displayName?: string) { + const currentGbKeys = this.groupByKeys(); + if (currentGbKeys.includes(gbKey)) { + const existing = this.activeChips().find( + (c) => c.isGroupBy && (c.key === 'group_by_' + gbKey || c.pillKey === gbKey), + ); + if (existing) { + this.removeFilterChip(existing); + } + } else { + this.addFilterChip(this.buildGroupByChip(gbKey, displayName)); + } + } + + /** Removes any filter chip matching the specified key. */ + removeChipForKey(key: string) { + const target: FilterChip = { + key, + pillKey: key, + pillCondition: '', + isGroupBy: false, + }; + const existing = this.activeChips().find((c) => + isSameFilterChip(c, target), + ); + if (existing) { + this.removeFilterChip(existing); + } else if (this.showValuePicker()) { + const currentPickerKey = this.pickerConfig()?.key; + if ( + currentPickerKey && + key.toLowerCase() === currentPickerKey.toLowerCase() + ) { + this.closeValuePicker(); + } + } + } + + // =========================================================================== + // 9. Lifecycle & State Reset Methods + // =========================================================================== + + /** Determines whether the ValuePicker is currently active for the specified chip. */ + isChipPickerActive(chip: FilterChip): boolean { + if (!this.showValuePicker() || chip.isGroupBy) return false; + const currentKey = normalizeKey(this.pickerKey()); + if (!currentKey) return false; + const targetKey = normalizeKey(chip.key || chip.pillKey); + return currentKey === targetKey; + } + + /** Determines whether the ValuePicker is currently active for the specified key and optional title. */ + isKeyPickerActive(key: string, title?: string): boolean { + if (!this.showValuePicker()) return false; + const currentKey = normalizeKey(this.pickerKey()); + if (!currentKey) return false; + const targetKey = normalizeKey(key); + if (currentKey !== targetKey) return false; + if ( + title && + this.pickerTitle() && + this.pickerTitle().toLowerCase() !== title.toLowerCase() + ) { + return false; + } + return true; + } + + /** Opens the ValuePicker popover for the specified key, configuring layout and initial state. */ + openValuePicker( + key: string, + anchor?: CdkOverlayOrigin | HTMLElement | null, + title?: string, + metadata?: unknown, + ) { + if (anchor) { + this.pickerAnchor.set(anchor); + } + const cleanKey = normalizeKey(key); + const currentKey = normalizeKey(this.pickerConfig()?.key); + const currentTitle = this.pickerConfig()?.title; + + if ( + this.showValuePicker() && + currentKey === cleanKey && + (!title || !currentTitle || currentTitle.toLowerCase() === title.toLowerCase()) + ) { + this.closeValuePicker(); + return; + } + + if (this.showValuePicker()) { + this.closeValuePicker(); + if (anchor) { + this.pickerAnchor.set(anchor); + } + } + + const activeChip = this.activeChips().find( + (c) => + !c.isGroupBy && + ((c.key && c.key.toLowerCase() === key.toLowerCase()) || + c.pillKey.toLowerCase() === cleanKey), + ); + + const config = this.buildPickerConfig(key, title, metadata); + const state = this.buildInitialPickerState(key, activeChip, metadata); + + this.pickerConfig.set(config); + this.pickerState.set(state); + this.showValuePicker.set(true); + } + + /** Closes the ValuePicker overlay and resets its configuration and temporary state. */ + closeValuePicker() { + this.showValuePicker.set(false); + this.pickerConfig.set(null); + this.pickerState.set(INITIAL_VALUE_PICKER_STATE); + this.pickerAnchor.set(null); + } + + /** Resets the entire search state back to default (clears query, restores default chips, closes overlays). */ + resetSearchState(updateUrl = true) { + this.searchQuery.set(''); + + const defaultChips = this.getDefaultChips(); + this.activeChips.set(defaultChips); + this.browseAll.set(false); + this.showSuggestions.set(false); + this.closeValuePicker(); + if (updateUrl) { + this.syncUrl(defaultChips); + } + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/tjs_search_store.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/tjs_search_store.ts new file mode 100644 index 0000000000..16873a7eb2 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/services/tjs_search_store.ts @@ -0,0 +1,821 @@ +import {computed, Injectable, signal} from '@angular/core'; +import {rxResource, takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop'; +import {Observable, of} from 'rxjs'; +import {catchError, map} from 'rxjs/operators'; +import { + Column, + Row, + TjsEntity, + TjsFilter, + TjsPromotedKey, + TjsResolveChipsRequest, + TjsSearchConfig, + TjsSearchRequest, + TjsSearchResponse, + TjsSuggestion, + TjsSuggestionRequest, +} from '../../../core/models/search'; +import { + EntityType, + FilterChip, + FilterKeyMetadata, + ParsedQueryFilter, + PickerValueItem, + PromotedFilterKeyItem, + SearchBoxSuggestion, + ValuePickerApplyEvent, + ValuePickerConfig, + ValuePickerState, +} from '../models'; +import { + extractFilterChipFromTjsSuggestion, + mapToSearchBoxSuggestion, +} from '../utils'; +import {SearchPageStore} from './search_page_store'; + +/** + * State store for Test / Job / Session (TJS) search pages. + * + * Extends base SearchPageStore with TJS-specific RPC communications, token-stack + * pagination, and single-level filter handling. + */ +@Injectable() +export class TjsSearchStore extends SearchPageStore { + // =========================================================================== + // 1. Dynamic UI Prompts & Suggestion Signals + // =========================================================================== + + /** Returns dynamic search placeholder text based on the active search entity. */ + override readonly searchPlaceholder = computed(() => { + switch (this.entity()) { + case 'tests': + return 'Paste an ID, or type a user or a test name'; + case 'jobs': + return 'Paste a job ID, or type a user or a job name'; + case 'sessions': + return 'Paste a session ID, or type a user'; + default: + return 'Type search query…'; + } + }); + + /** Reactive resource fetching auto-complete suggestions with debounce. */ + private readonly suggestionsResource = rxResource< + TjsSuggestion[], + | { + entity: EntityType; + input: string; + filters: TjsFilter[]; + } + | undefined + >({ + params: () => { + const e = this.entity(); + if (e !== 'tests' && e !== 'jobs' && e !== 'sessions') return undefined; + const show = this.showSuggestions(); + if (!show) return undefined; + + return { + entity: e, + input: this.debouncedSearchQuery(), + filters: this.getAllEffectiveFilters(), + }; + }, + stream: ({params: req}) => { + if (!req) return of([]); + const tjsReq: TjsSuggestionRequest = { + entity: this.getTjsEntityProto(req.entity), + input: req.input, + filters: req.filters.length > 0 ? req.filters : undefined, + limit: 10, + }; + return this.searchService.getTjsSuggestions(tjsReq).pipe( + map((res) => res.items || []), + catchError(() => of([])), + ); + }, + }); + + /** Exposes current array of raw TJS suggestions. */ + readonly tjsSuggestions = computed( + () => this.suggestionsResource.value() || [], + ); + + /** Mapped search suggestions for SearchBox popover consumption. */ + override readonly suggestions = computed(() => { + return this.tjsSuggestions().map(mapToSearchBoxSuggestion); + }); + + // =========================================================================== + // 2. Page Configuration & Metadata Resources + // =========================================================================== + + /** Reactive resource fetching TJS search configuration (default chips, promoted keys). */ + private readonly searchConfigResource = rxResource< + TjsSearchConfig | null, + EntityType | undefined + >({ + params: () => { + const e = this.entity(); + if (e !== 'tests' && e !== 'jobs' && e !== 'sessions') return undefined; + return e; + }, + stream: ({params: entity}) => { + if (!entity) return of(null); + return this.searchService + .getTjsSearchConfig({entity: this.getTjsEntityProto(entity)}) + .pipe(catchError(() => of(null))); + }, + }); + + /** Exposes current TJS search configuration. */ + override readonly searchConfig = computed( + () => this.searchConfigResource.value() || null, + ); + + /** Whether initial search configuration is loading. */ + override readonly isConfigLoading = computed( + () => this.searchConfigResource.isLoading(), + ); + + /** Whether auxiliary promoted keys are currently loading. */ + override readonly isPromotedKeysLoading = computed( + () => this.searchConfigResource.isLoading(), + ); + + constructor() { + super(); + + // When initial search config loads on a clean URL, populate default chips + toObservable(this.searchConfig) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((cfg) => { + const queryParams = this.route.snapshot?.queryParams; + const hasUrlFilters = queryParams?.['f'] || queryParams?.['gb']; + if (cfg && !hasUrlFilters && this.activeChips().length === 0) { + const defaults = this.mapDefaultChips(cfg); + if (defaults.length > 0) { + this.activeChips.set(defaults); + } + } + }); + } + + /** Returns default chips from active search configuration if available. */ + override getDefaultChips(): FilterChip[] { + return this.mapDefaultChips(this.searchConfig()); + } + + /** Helper to map TjsSearchConfig default chips to FilterChip array. */ + private mapDefaultChips(cfg: TjsSearchConfig | null): FilterChip[] { + if (cfg?.defaultChips && cfg.defaultChips.length > 0) { + return cfg.defaultChips.map((c) => { + let rawVals: string[] | undefined; + if (c.filter?.enumValues?.values) { + rawVals = c.filter.enumValues.values; + } else if (c.filter?.stringValue?.value) { + rawVals = [c.filter.stringValue.value]; + } else if (c.filter?.namedValue) { + rawVals = [c.filter.namedValue.name, c.filter.namedValue.value]; + } + const key = c.filter?.key || c.pillKey; + const meta = + this.keyMetadataMap().get(c.pillKey?.toLowerCase()) || + this.keyMetadataMap().get(key); + return { + key, + pillKey: c.pillKey || c.keyDisplayName || meta?.keyDisplayName || key, + pillCondition: c.pillCondition || c.filter?.stringValue?.value || '', + rawValues: rawVals, + metadata: meta, + }; + }); + } + return []; + } + + /** Promoted filter key preset items derived directly from active TJS search config. */ + override readonly promotedFilterKeys = computed( + () => { + const cfg = this.searchConfig(); + if (!cfg?.promotedKeys) return []; + return cfg.promotedKeys.map((pk: TjsPromotedKey) => ({ + key: pk.key, + metadata: { + ...pk, + key: pk.key, + keyDisplayName: pk.displayName || pk.key, + }, + })); + }, + ); + + /** Dynamic dictionary map containing metadata for all known filter keys. */ + override readonly keyMetadataMap = computed>( + () => { + const nextMap = new Map(); + const cfg = this.searchConfig(); + if (cfg?.promotedKeys) { + for (const pk of cfg.promotedKeys) { + const meta: FilterKeyMetadata = { + ...pk, + key: pk.key, + keyDisplayName: pk.displayName || pk.key, + }; + nextMap.set(pk.key, meta); + if (pk.displayName) { + nextMap.set(pk.displayName.toLowerCase(), meta); + } + } + } + return nextMap; + }, + ); + + // =========================================================================== + // 3. Primary Search Resource & Execution + // =========================================================================== + + /** Reactive resource fetching main TJS search results data. */ + private readonly tjsResource = rxResource< + TjsSearchResponse, + | { + entity: EntityType; + pageToken: string; + filters: TjsFilter[]; + } + | undefined + >({ + params: () => { + const e = this.entity(); + if (e !== 'tests' && e !== 'jobs' && e !== 'sessions') return undefined; + + // On clean page entry, wait for searchConfig to load and populate default chips + const queryParams = this.route.snapshot?.queryParams; + const hasUrlFilters = queryParams?.['f'] || queryParams?.['gb']; + if (!hasUrlFilters) { + const cfg = this.searchConfig(); + if (!cfg) { + return undefined; + } + if (this.activeChips().length === 0 && cfg.defaultChips && cfg.defaultChips.length > 0) { + return undefined; + } + } + + return { + entity: e, + pageToken: this.pageToken(), + filters: this.getAllEffectiveFilters(), + }; + }, + stream: ({params: req}) => { + if (!req) return of({columns: [], rows: []} as TjsSearchResponse); + + const tjsReq: TjsSearchRequest = { + entity: this.getTjsEntityProto(req.entity), + filters: req.filters.length > 0 ? req.filters : undefined, + pageToken: req.pageToken || undefined, + }; + + return this.searchService.searchTjs(tjsReq).pipe( + catchError(() => + of({ + columns: [], + rows: [], + } as TjsSearchResponse), + ), + ); + }, + }); + + /** Exposes loading status of primary TJS search resource. */ + override readonly isLoading = computed(() => this.tjsResource.isLoading()); + + /** Columns to render in TJS search results table. */ + override readonly displayColumns = computed(() => { + return this.tjsResource.value()?.columns || []; + }); + + /** Rows to render in TJS search results table. */ + override readonly rows = computed(() => { + return (this.tjsResource.value()?.rows as Row[]) || []; + }); + + /** Determines whether a next page token exists in TJS search response. */ + override readonly hasNextPage = computed(() => { + return !!this.tjsResource.value()?.nextPageToken; + }); + + /** Triggers execution of primary TJS search and forces resource reload. */ + override executeSearch() { + this.pageIndex.set(0); + this.pageToken.set(''); + this.prevPageTokens.set([]); + this.tjsResource.reload(); + } + + /** Maps frontend EntityType string enum to backend Protobuf TjsEntity enum. */ + private getTjsEntityProto(e: EntityType): TjsEntity { + switch (e) { + case 'tests': + return TjsEntity.TJS_ENTITY_TEST; + case 'jobs': + return TjsEntity.TJS_ENTITY_JOB; + case 'sessions': + return TjsEntity.TJS_ENTITY_SESSION; + default: + return TjsEntity.TJS_ENTITY_TEST; + } + } + + /** Combines active filter chips into backend TjsFilter array. */ + private getAllEffectiveFilters(): TjsFilter[] { + return this.activeChips() + .map((chip) => this.buildTjsFilterFromChip(chip)) + .filter(Boolean) as TjsFilter[]; + } + + /** Maps FilterChip to TjsFilter Protobuf payload. */ + private buildTjsFilterFromChip(chip: FilterChip): TjsFilter | null { + if (!chip.key && !chip.pillKey) return null; + const key = chip.key || chip.pillKey; + + const meta = + chip.metadata || + this.keyMetadataMap().get(chip.pillKey.toLowerCase()) || + this.keyMetadataMap().get(key); + const tjsMeta = meta as unknown as TjsPromotedKey | undefined; + + if (tjsMeta?.timeRange || key === 'create_time') { + const cond = chip.pillCondition || ''; + const parts = cond.split('~').map((s) => s.trim()); + return { + key, + timeRange: { + from: this.toIsoString(parts[0] || ''), + to: this.toIsoString(parts[1] || ''), + }, + }; + } + + if ( + tjsMeta?.namedPair || + key === 'dimension' || + (key === 'property' && chip.pillKey.toLowerCase() === 'property') + ) { + const cond = chip.pillCondition || ''; + const idx = cond.indexOf(':'); + const name = + idx !== -1 ? cond.substring(0, idx) : chip.rawValues?.[0] || ''; + const value = + idx !== -1 + ? cond.substring(idx + 1) + : chip.rawValues?.[1] || chip.rawValues?.[0] || cond; + return { + key, + namedValue: { + name, + value, + }, + }; + } + + if (tjsMeta?.enumPicker) { + const vals = + chip.rawValues && chip.rawValues.length > 0 + ? chip.rawValues + : chip.pillCondition + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return { + key, + enumValues: { + values: vals, + }, + }; + } + + if (chip.rawValues && chip.rawValues.length > 0) { + return { + key, + stringValue: { + value: chip.rawValues[0] || chip.rawValues.join(','), + }, + }; + } + + if (chip.pillCondition) { + return { + key, + stringValue: { + value: chip.pillCondition, + }, + }; + } + + return null; + } + + /** Converts ISO or timestamp string to ISO format string. */ + private toIsoString(val: string): string | undefined { + if (!val) return undefined; + const d = new Date(val); + return isNaN(d.getTime()) ? val : d.toISOString(); + } + + // =========================================================================== + // 4. Pagination Stack & History Controls + // =========================================================================== + + /** History stack of page tokens used for backward pagination. */ + readonly prevPageTokens = signal([]); + + /** Navigates to previous page token by popping from token history stack. */ + override prevPage() { + const tokens = this.prevPageTokens(); + if (tokens.length === 0) return; + const prev = tokens[tokens.length - 1]; + this.prevPageTokens.set(tokens.slice(0, tokens.length - 1)); + this.pageToken.set(prev); + this.pageIndex.update((i) => Math.max(0, i - 1)); + } + + /** Navigates to next page token and records current token onto pagination stack. */ + override nextPage() { + const nextToken = this.tjsResource.value()?.nextPageToken; + if (!nextToken) return; + this.prevPageTokens.update((tokens) => [...tokens, this.pageToken()]); + this.pageToken.set(nextToken); + this.pageIndex.update((i) => i + 1); + } + + // =========================================================================== + // 5. URL Resolution & Fallback Chip Construction + // =========================================================================== + + /** Overrides base fallback chip builder to exclude polarity, advanced match, and group-by. */ + override buildFallbackChip(pf: ParsedQueryFilter): FilterChip { + const meta = + this.keyMetadataMap().get(pf.fallbackPillCondition.toLowerCase()) || + this.keyMetadataMap().get(pf.key); + return { + key: pf.key, + pillKey: meta?.keyDisplayName || pf.key, + pillCondition: pf.fallbackPillCondition, + rawValues: pf.rawValues, + metadata: meta, + }; + } + + /** Resolves parsed URL query filters into FilterChip list using TJS resolve API. */ + protected override resolveChipsFromBackend( + parsedFilters: ParsedQueryFilter[], + _groupByKeys: string[], + ): Observable { + if (parsedFilters.length === 0) { + return of([]); + } + + const req: TjsResolveChipsRequest = { + filters: parsedFilters.map((pf) => pf.tjsFilter), + }; + + return this.searchService.resolveTjsChips(req).pipe( + map((res) => { + const updatedChips: FilterChip[] = []; + if (res.chips && res.chips.length > 0) { + res.chips.forEach((c, idx) => { + const pf = parsedFilters[idx]; + const meta = pf?.key + ? this.keyMetadataMap().get(c.pillKey?.toLowerCase()) || + this.keyMetadataMap().get(pf.key) + : undefined; + updatedChips.push({ + key: pf?.key, + pillKey: c.pillKey, + pillCondition: c.pillCondition, + rawValues: pf?.rawValues, + metadata: meta, + }); + }); + } + return updatedChips; + }), + ); + } + + // =========================================================================== + // 6. ValuePicker Application & TJS Filter Helpers + // =========================================================================== + + /** Constructs TJS-specific ValuePicker configuration based on key metadata. */ + protected override buildPickerConfig( + key: string, + title?: string, + metadata?: unknown, + ): ValuePickerConfig { + const meta = + (metadata as FilterKeyMetadata) || + (title ? this.keyMetadataMap().get(title.toLowerCase()) : undefined) || + this.keyMetadataMap().get(key); + const displayTitle = meta?.keyDisplayName || title || key; + const tjsMeta = meta as unknown as TjsPromotedKey | undefined; + + let layoutType: 'list' | 'range' | 'namedPair' | 'text' = 'list'; + let needsName = false; + let namePlaceholder = 'Name'; + let valPlaceholder = 'Value'; + + if (tjsMeta?.timeRange || key === 'create_time') { + layoutType = 'range'; + } else if (tjsMeta?.enumPicker) { + layoutType = 'list'; + } else if ( + tjsMeta?.namedPair || + (key === 'property' && displayTitle.toLowerCase() === 'property') || + key === 'dimension' + ) { + layoutType = 'namedPair'; + needsName = true; + namePlaceholder = tjsMeta?.namedPair?.namePlaceholder || 'Property name'; + valPlaceholder = + tjsMeta?.namedPair?.valuePlaceholder || 'Property value'; + } else if (tjsMeta?.textInput) { + layoutType = 'text'; + valPlaceholder = tjsMeta.textInput.placeholder || 'Value'; + } else { + layoutType = 'text'; + } + + return { + key, + type: layoutType, + title: displayTitle, + needsName, + canUseAdvanced: false, + isPlural: !!meta?.isPlural, + showNegateToggle: false, + showAdvancedMenu: false, + showSearchInput: layoutType === 'list', + showRowActions: false, + namePlaceholder, + valPlaceholder, + }; + } + + /** Constructs initial ValuePicker interaction state for TJS filter keys. */ + protected override buildInitialPickerState( + key: string, + activeChip?: FilterChip, + metadata?: unknown, + ): ValuePickerState { + const meta = + (metadata as FilterKeyMetadata) || + (activeChip?.pillKey + ? this.keyMetadataMap().get(activeChip.pillKey.toLowerCase()) + : undefined) || + this.keyMetadataMap().get(key); + const tjsMeta = meta as unknown as TjsPromotedKey | undefined; + + let enumOptions: PickerValueItem[] = []; + if (tjsMeta?.enumPicker?.options && tjsMeta.enumPicker.options.length > 0) { + enumOptions = tjsMeta.enumPicker.options.map((o) => ({ + value: o.value, + displayLabel: o.label || o.value, + })); + } + + let selectedVals = new Set(); + if (activeChip) { + if (activeChip.rawValues && activeChip.rawValues.length > 0) { + selectedVals = new Set(activeChip.rawValues); + } else if (activeChip.pillCondition) { + const existingVals = activeChip.pillCondition + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); + selectedVals = new Set(existingVals); + } + } + + return { + loading: false, + values: enumOptions, + selectedValues: selectedVals, + negated: false, + advanced: { + active: false, + mode: 'prefix', + text: '', + values: [], + }, + }; + } + + /** Handles ValuePicker apply events by resolving chips from API or falling back locally. */ + override applyValuePicker(event: ValuePickerApplyEvent) { + const key = this.pickerKey(); + const title = this.pickerTitle(); + const meta = key + ? this.keyMetadataMap().get(title.toLowerCase()) || + this.keyMetadataMap().get(key) + : undefined; + const displayTitle = meta?.keyDisplayName || title; + this.closeValuePicker(); + + const tjsFilter = this.buildTjsFilter(key, event, meta); + if (!tjsFilter) { + this.removeChipForKey(key); + return; + } + + const req: TjsResolveChipsRequest = {filters: [tjsFilter]}; + this.searchService + .resolveTjsChips(req) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (res) => { + if (res.chips && res.chips.length > 0) { + const c = res.chips[0]; + this.addFilterChip({ + key, + pillKey: c.pillKey || displayTitle, + pillCondition: c.pillCondition, + rawValues: this.extractRawValues(event), + metadata: meta, + }); + } else { + this.applyTjsChipLocally(event, key, displayTitle, meta); + } + }, + error: () => { + this.applyTjsChipLocally(event, key, displayTitle, meta); + }, + }); + } + + /** Builds a TjsFilter Protobuf request object from a ValuePickerApplyEvent. */ + private buildTjsFilter( + key: string, + event: ValuePickerApplyEvent, + meta?: FilterKeyMetadata, + ): TjsFilter | null { + if (event.rangeFrom || event.rangeTo) { + const fromVal = event.rangeFrom?.trim() || ''; + const toVal = event.rangeTo?.trim() || ''; + return { + key, + timeRange: { + from: this.toIsoString(fromVal), + to: this.toIsoString(toVal), + }, + }; + } + + const tjsMeta = meta as unknown as TjsPromotedKey | undefined; + + if ( + tjsMeta?.namedPair || + event.propName || + (key === 'property' && meta?.keyDisplayName?.toLowerCase() === 'property') || + key === 'dimension' + ) { + const propName = event.propName?.trim() || ''; + const propVal = + event.textVal?.trim() || + (event.selected && event.selected[0]) || + ''; + if (!propName && !propVal) return null; + return { + key, + namedValue: { + name: propName, + value: propVal, + }, + }; + } + + if (tjsMeta?.enumPicker) { + if (event.selected && event.selected.length > 0) { + return { + key, + enumValues: { + values: event.selected, + }, + }; + } + return null; + } + + if (event.textVal?.trim()) { + return { + key, + stringValue: { + value: event.textVal.trim(), + }, + }; + } + + if (event.selected && event.selected.length > 0) { + return { + key, + stringValue: { + value: event.selected[0], + }, + }; + } + + return null; + } + + /** Formats fallback pill condition string from ValuePickerApplyEvent. */ + private formatTjsFallbackCondition(event: ValuePickerApplyEvent): string { + if (event.rangeFrom || event.rangeTo) { + return `${event.rangeFrom || ''} ~ ${event.rangeTo || ''}`.trim(); + } + if (event.selected && event.selected.length > 0) { + return event.selected.join(', '); + } + if (event.propName || event.textVal) { + return `${event.propName || ''}:${event.textVal || ''}`; + } + if (event.textVal?.trim()) { + return event.textVal.trim(); + } + return ''; + } + + /** Helper method to locally insert a fallback TJS filter chip into state. */ + private applyTjsChipLocally( + event: ValuePickerApplyEvent, + key: string, + displayTitle: string, + meta?: FilterKeyMetadata, + ) { + const condition = this.formatTjsFallbackCondition(event); + if (!condition) return; + + this.addFilterChip({ + key, + pillKey: displayTitle, + pillCondition: condition, + rawValues: this.extractRawValues(event), + metadata: meta, + }); + } + + /** Extracts raw values string array from ValuePickerApplyEvent. */ + private extractRawValues(event: ValuePickerApplyEvent): string[] | undefined { + if (event.selected && event.selected.length > 0) { + return event.selected; + } + if (event.textVal?.trim()) { + return [event.textVal.trim()]; + } + return undefined; + } + + // =========================================================================== + // 7. Suggestion Selection, Chip Mutators, & State Reset + // =========================================================================== + + /** Directly resolves and applies test/job/session suggestion filters to store. */ + override selectSuggestion(item: SearchBoxSuggestion) { + const raw = item.rawItem as TjsSuggestion; + if (!raw) return; + + this.showSuggestions.set(false); + this.searchQuery.set(''); + + this.applyTjsSuggestionDirectly(raw); + } + + /** Directly applies a TjsSuggestion locally without redundant backend resolution. */ + private applyTjsSuggestionDirectly(item: TjsSuggestion) { + const chip = extractFilterChipFromTjsSuggestion(item); + if (chip) { + this.addFilterChip(chip); + } + } + + /** Appends or updates a filter chip and clears pagination token history. */ + override addFilterChip(chip: FilterChip) { + this.prevPageTokens.set([]); + super.addFilterChip(chip); + } + + /** Removes a filter chip and clears pagination token history. */ + override removeFilterChip(chip: FilterChip) { + this.prevPageTokens.set([]); + super.removeFilterChip(chip); + } + + /** Fully resets search query, active chips, and pagination history. */ + override resetSearchState(updateUrl = true) { + super.resetSearchState(updateUrl); + this.prevPageTokens.set([]); + } + +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/index.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/index.ts new file mode 100644 index 0000000000..9d668b41d3 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/index.ts @@ -0,0 +1,4 @@ +export * from './search_cell_utils'; +export * from './search_filter_utils'; +export * from './search_pipes'; +export * from './value_picker_utils'; diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_cell_utils.spec.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_cell_utils.spec.ts new file mode 100644 index 0000000000..ce3179affa --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_cell_utils.spec.ts @@ -0,0 +1,120 @@ +import {Column, Indicator, Row} from '../../../core/models/search'; +import { + getCell, + getCellType, + getRouterLink, + getRowId, + getStatusClass, + getTextValue, + isTimeColumn, +} from './search_cell_utils'; + +describe('search_cell_utils', () => { + describe('isTimeColumn', () => { + it('identifies time-related columns correctly', () => { + expect(isTimeColumn('timestamp')).toBeTrue(); + expect(isTimeColumn('start_time')).toBeTrue(); + expect(isTimeColumn('created_at')).toBeTrue(); + expect(isTimeColumn('device_id')).toBeFalse(); + expect(isTimeColumn(undefined)).toBeFalse(); + }); + }); + + describe('getCell', () => { + const columns: Column[] = [ + {key: 'id', displayName: 'ID'}, + {key: 'status', displayName: 'Status'}, + ]; + + it('returns cell matching column index', () => { + const row: Row = { + id: 'row-1', + cells: [ + {text: {value: 'dev-123'}}, + {status: {text: 'IDLE'}}, + ], + }; + expect(getCell(row, 'id', columns)).toEqual({text: {value: 'dev-123'}}); + expect(getCell(row, 'status', columns)).toEqual({status: {text: 'IDLE'}}); + expect(getCell(row, 'unknown', columns)).toBeNull(); + expect(getCell(undefined, 'id', columns)).toBeNull(); + }); + }); + + describe('getTextValue', () => { + it('extracts string from text cell', () => { + expect(getTextValue({text: {value: 'hello'}})).toBe('hello'); + expect(getTextValue({text: {value: 'hello_obj'}})).toBe('hello_obj'); + }); + + it('extracts string from link, status, chips, multilink', () => { + expect(getTextValue({link: {text: 'link_txt'}})).toBe('link_txt'); + expect(getTextValue({status: {text: 'PASS'}})).toBe('PASS'); + expect(getTextValue({chips: {values: ['a', 'b']}})).toBe('a, b'); + expect( + getTextValue({ + multiLink: {entries: [{text: 'x'}, {text: 'y'}]}, + }), + ).toBe('x, y'); + expect(getTextValue(undefined)).toBeNull(); + }); + }); + + describe('getCellType', () => { + it('identifies cell types correctly', () => { + expect(getCellType({link: {text: 'l'}})).toBe('link'); + expect(getCellType({status: {text: 's'}})).toBe('status'); + expect(getCellType({chips: {values: []}})).toBe('chips'); + expect(getCellType({multiLink: {entries: []}})).toBe('multilink'); + expect(getCellType({text: {value: 't'}})).toBe('text'); + expect(getCellType(undefined)).toBe('unknown'); + }); + }); + + describe('getRouterLink', () => { + it('builds device, host, job, session, test router links', () => { + expect(getRouterLink({target: {device: {id: 'd1'}}})).toEqual(['/devices', 'd1']); + expect(getRouterLink({target: {host: {hostName: 'h1'}}})).toEqual(['/hosts', 'h1']); + expect(getRouterLink({target: {job: {jobId: 'j1'}}})).toEqual(['/jobs', 'j1']); + expect(getRouterLink({target: {session: {sessionId: 's1'}}})).toEqual(['/sessions', 's1']); + expect( + getRouterLink({target: {test: {jobId: 'j1', testId: 't1'}}}), + ).toEqual(['/jobs', 'j1', 'tests', 't1']); + expect(getRouterLink(undefined)).toBeNull(); + }); + }); + + describe('getStatusClass', () => { + it('evaluates status class from indicator or text', () => { + expect(getStatusClass({status: {text: 'OK', indicator: Indicator.INDICATOR_OK}})).toBe('status-ok'); + expect(getStatusClass({status: {text: 'RUNNING'}})).toBe('status-active'); + expect(getStatusClass({status: {text: 'FAIL'}})).toBe('status-error'); + expect(getStatusClass({status: {text: 'UNKNOWN'}})).toBe('status-neutral'); + expect(getStatusClass(undefined)).toBe('status-neutral'); + }); + }); + + describe('getRowId', () => { + it('returns valid raw ID', () => { + expect(getRowId({id: 'device-abc-123'})).toBe('device-abc-123'); + }); + + it('filters protobuf placeholder all-zeros ID and falls back to link target or cell text', () => { + const rowWithZeroIdAndLink: Row = { + id: '000000000000', + cells: [{link: {target: {device: {id: 'real-device-id'}}}}], + }; + expect(getRowId(rowWithZeroIdAndLink)).toBe('real-device-id'); + + const rowWithGuidPlaceholder: Row = { + id: '00000000-0000-0000-0000-000000000000', + cells: [{text: {value: 'text-fallback-id'}}], + }; + expect(getRowId(rowWithGuidPlaceholder)).toBe('text-fallback-id'); + }); + + it('handles undefined row', () => { + expect(getRowId(undefined)).toBe(''); + }); + }); +}); diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_cell_utils.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_cell_utils.ts new file mode 100644 index 0000000000..3897b34907 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_cell_utils.ts @@ -0,0 +1,194 @@ +import { + Cell, + Column, + LinkCell, + LinkEntry, + NavTarget, + Row, +} from '../../../core/models/search'; +import {dateUtils} from '../../../shared/utils/date_utils'; + +/** Pre-compiled regex for identifying time/date column keys. */ +const TIME_COLUMN_KEY_REGEX = + /time|date|timestamp|created|updated|start_time|end_time|last_seen/i; + +/** Helper to check if a column key represents a timestamp/date column. */ +export function isTimeColumn(colKey: string | undefined): boolean { + return !!colKey && TIME_COLUMN_KEY_REGEX.test(colKey); +} + +/** Constant sets for O(1) status class evaluation to prevent GC allocations per cell render. */ +const STATUS_OK_SET = new Set([ + 'PASS', + 'PASSED', + 'DONE', + 'FINISHED', + 'SUCCEEDED', + 'COMPLETED', + 'HEALTHY', + 'IDLE', + 'READY', +]); +const STATUS_ACTIVE_SET = new Set(['RUNNING', 'ACTIVE']); +const STATUS_ERROR_SET = new Set([ + 'FAIL', + 'ERROR', + 'FAILED', + 'ABORT', + 'TIMEOUT', + 'CANCELLED', + 'EXPIRED', + 'BUSY', + 'OFFLINE', + 'DRAINING', +]); + +/** Mapping of protobuf Indicator enum values to CSS status classes. */ +const INDICATOR_CLASS_MAP: Record = { + 'INDICATOR_GOOD': 'status-ok', + 'INDICATOR_OK': 'status-ok', + 1: 'status-ok', + + 'INDICATOR_ACTIVE': 'status-active', + 2: 'status-active', + + 'INDICATOR_ERROR': 'status-error', + 3: 'status-error', +}; + +/** Resolves the Cell element for a column from a Row. */ +export function getCell( + row: Row | Record | undefined, + colKey: string, + columns: Column[], +): Cell | null { + if (!row) return null; + const cells = + (row as Row).cells || ((row as Record)['cells'] as Cell[]); + if (!cells || !Array.isArray(cells)) return null; + const idx = columns.findIndex((c) => c.key === colKey); + return idx === -1 ? null : cells[idx]; +} + +/** Extracts the string value representation of a Cell (strictly matching the 5 Proto Cell oneof fields). */ +export function getTextValue(cell: Cell | undefined): string | null { + if (!cell) return null; + + if (typeof cell.text === 'string') return cell.text || null; + if (typeof cell.text === 'object' && cell.text?.value != null) { + return cell.text.value; + } + if (cell.link?.text != null) return cell.link.text; + if (cell.status?.text != null) return cell.status.text; + if (cell.chips?.values?.length) return cell.chips.values.join(', '); + if (cell.multiLink?.entries?.length) { + return cell.multiLink.entries + .map((e) => e.text) + .filter(Boolean) + .join(', '); + } + return null; +} + +/** Determines the semantic type classification for a Cell (strictly matching the 5 Proto oneof kinds in search_common.proto). */ +export function getCellType( + cell: Cell | undefined, +): 'text' | 'link' | 'status' | 'chips' | 'multilink' | 'unknown' { + if (!cell) return 'unknown'; + + if (cell.link) return 'link'; + if (cell.status) return 'status'; + if (cell.chips) return 'chips'; + if (cell.multiLink) return 'multilink'; + if (cell.text) return 'text'; + + return 'unknown'; +} + +/** Constructs RouterLink path segments for LinkCell, LinkEntry, or NavTarget (100% aligns with search_common.proto). */ +export function getRouterLink( + item: LinkCell | LinkEntry | NavTarget | undefined, +): string[] | null { + if (!item) return null; + + const target: NavTarget | undefined = + (item as LinkCell | LinkEntry).target || (item as NavTarget); + + if (!target) return null; + + if (target.device?.id) return ['/devices', target.device.id]; + if (target.host?.hostName) return ['/hosts', target.host.hostName]; + if (target.job?.jobId) return ['/jobs', target.job.jobId]; + if (target.session?.sessionId) return ['/sessions', target.session.sessionId]; + if (target.test?.testId) { + const jobId = target.test.jobId || 'unknown_job'; + return ['/jobs', jobId, 'tests', target.test.testId]; + } + + return null; +} + +/** Computes the CSS status class name for cell status indicators (100% aligns with search_common.proto StatusCell). */ +export function getStatusClass(cell: Cell | undefined): string { + if (!cell) return 'status-neutral'; + + const indicator = cell.status?.indicator; + if (indicator != null) { + const indStr = String(indicator); + if (indStr !== 'INDICATOR_UNSPECIFIED' && indStr !== '0') { + return ( + INDICATOR_CLASS_MAP[indicator as string | number] || 'status-neutral' + ); + } + } + + const rawStatus = cell.status?.text || ''; + const txt = String(rawStatus).toUpperCase(); + + if (STATUS_ACTIVE_SET.has(txt)) return 'status-active'; + if (STATUS_OK_SET.has(txt)) return 'status-ok'; + if (STATUS_ERROR_SET.has(txt)) return 'status-error'; + return 'status-neutral'; +} + +/** Formats a UTC timestamp string into a local detailed date-time string with timezone. */ +export function formatTime(val: string | undefined | null): string | null { + if (!val) return null; + + const date = dateUtils.parseUtcTimestamp(val); + if (!date || isNaN(date.getTime())) return val; + return dateUtils.formatDetailedLocal(date); +} + +/** + * Extracts a unique string identifier for a search result row. + * Filters out default Protobuf all-zero placeholder UUIDs ('000000000000') and + * falls back to primary cell text/link metadata to ensure rows are uniquely identifiable. + */ +export function getRowId(row: Row | undefined): string { + if (!row) return ''; + const rawId = row.id || ''; + // Ignore default unassigned Protobuf placeholder UUIDs to prevent ID collisions in selection Set + if ( + rawId && + rawId !== '000000000000' && + rawId !== '00000000-0000-0000-0000-000000000000' + ) { + return rawId; + } + + // Fall back to primary cell metadata (device ID or host name) if rawId is a dummy placeholder + const c0 = row.cells?.[0]; + if (c0) { + const link = c0.link; + const target = link?.target; + const cellText = + target?.device?.id || + target?.host?.hostName || + link?.text || + (typeof c0.text === 'string' ? c0.text : c0.text?.value); + if (cellText) return String(cellText); + } + return rawId; +} + diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_filter_utils.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_filter_utils.ts new file mode 100644 index 0000000000..ccc7538766 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_filter_utils.ts @@ -0,0 +1,623 @@ +import { + ComplexMatch, + Filter, + FleetGroupSort, + FleetSuggestion, + TjsFilter, + TjsSuggestion, +} from '../../../core/models/search'; + +import { + ADV_MODES_LIST, + AdvancedMatchMode, + ComplexMatchInfo, + FilterChip, + ParsedQueryFilter, + SearchBoxSuggestion, + ValuePickerApplyEvent, +} from '../models'; +export * from '../models'; + +/** Pre-compiled regex for parsing complex search filter conditions. */ +const COMPLEX_CONDITION_REGEX = + /^(starts with|starts|prefix|startswith|starts_with|does not contain|not contain|doesnt contain|not_substring|not substring|contains substring|contains_substring|contains|substring|does not match regex|doesnt match regex|not_regex|not regex|matches regex|matches_regex|regex|matches exactly|matches_exactly|is exactly|exactly|matches at least|matches_at_least|is at least|at least|at_least|atleast|matches|does not match|doesnt match|has all of)\b[\s:\/]*"?([^"\/]*)"?\/?$/i; + +// ============================================================================ +// ValuePicker Dialog ↔ ComplexMatch AST (Advanced Matching) +// ============================================================================ + +/** Helper to normalize any raw condition phrase into a canonical mode key matching ADV_MODES_LIST. */ +function normalizeRawMode(rawMode: string): string { + const norm = rawMode.toLowerCase().trim(); + if (norm.includes('start') || norm.includes('prefix')) return 'prefix'; + if (norm.includes('contain') || norm.includes('substring')) { + return norm.includes('not') || norm.includes('doesnt') + ? 'not_substring' + : 'substring'; + } + if ( + norm.includes('regex') || + norm === 'matches' || + norm === 'does not match' || + norm === 'doesnt match' + ) { + return norm.includes('not') || norm.includes('doesnt') + ? 'not_regex' + : 'regex'; + } + if (norm.includes('least') || norm === 'has all of') return 'at_least'; + return 'exactly'; +} + +/** Parses raw pillCondition text string into structured ComplexMatchInfo. */ +export function parseComplexCondition( + pillCondition?: string, + rawValues?: string[], + negated?: boolean, +): ComplexMatch | undefined { + if (!pillCondition) return undefined; + const match = pillCondition.match(COMPLEX_CONDITION_REGEX); + if (!match) return undefined; + + const rawMode = match[1]; + const targetVal = match[2]?.trim() || ''; + const modeKey = normalizeRawMode(rawMode); + + const valuesArr = + modeKey === 'exactly' || modeKey === 'at_least' + ? targetVal + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : rawValues?.length + ? rawValues + : targetVal + ? [targetVal] + : []; + + switch (modeKey) { + case 'prefix': + return {startsWith: {value: targetVal}}; + case 'substring': + return {containsSubstring: {value: targetVal, negated: false}}; + case 'not_substring': + return {containsSubstring: {value: targetVal, negated: true}}; + case 'regex': + return {matchesRegex: {value: targetVal, negated: false}}; + case 'not_regex': + return {matchesRegex: {value: targetVal, negated: true}}; + case 'at_least': + return {matchesAtLeast: {values: valuesArr}}; + case 'exactly': + default: + return {matchesExactly: {values: valuesArr}}; + } +} + +/** Converts a ComplexMatch Protobuf payload back to human-readable ComplexMatchInfo for UI display. */ +export function extractComplexMatchInfo( + complex?: ComplexMatch, + fallbackCondition?: string, + negated?: boolean, +): ComplexMatchInfo | undefined { + if (!complex) { + const parsed = parseComplexCondition(fallbackCondition, undefined, negated); + if (!parsed) return undefined; + complex = parsed; + } + + if (complex.startsWith?.value) { + return { + mode: 'prefix', + values: [complex.startsWith.value], + isNegated: !!negated, + }; + } + if (complex.containsSubstring?.value) { + return { + mode: complex.containsSubstring.negated ? 'not_substring' : 'substring', + values: [complex.containsSubstring.value], + isNegated: !!complex.containsSubstring.negated || !!negated, + }; + } + if (complex.matchesRegex?.value) { + return { + mode: complex.matchesRegex.negated ? 'not_regex' : 'regex', + values: [complex.matchesRegex.value], + isNegated: !!complex.matchesRegex.negated || !!negated, + }; + } + if (complex.matchesExactly?.values?.length) { + return { + mode: 'exactly', + values: complex.matchesExactly.values, + isNegated: !!negated, + }; + } + if (complex.matchesAtLeast?.values?.length) { + return { + mode: 'at_least', + values: complex.matchesAtLeast.values, + isNegated: !!negated, + }; + } + + return undefined; +} + +/** Helper to extract advanced matching state from a FilterChip for ValuePicker initialization. */ +export function extractAdvancedStateFromChip(chip: FilterChip): { + isAdv: boolean; + advMode: AdvancedMatchMode; + advText: string; + advValues: string[]; +} { + const info = extractComplexMatchInfo( + chip.complex, + chip.pillCondition, + chip.negated, + ); + if (info) { + return { + isAdv: true, + advMode: info.mode, + advText: info.values.join(', '), + advValues: info.values, + }; + } + return { + isAdv: false, + advMode: 'substring', + advText: '', + advValues: [], + }; +} + +/** Builds ComplexMatch Protobuf structure from Popover ValuePickerApplyEvent. */ +export function buildComplexMatchFromEvent( + event: ValuePickerApplyEvent, +): ComplexMatch | undefined { + if (!event.isAdvanced || !event.advMode) return undefined; + + const mode = event.advMode; + const txt = event.advText?.trim() || ''; + + switch (mode) { + case 'prefix': + return {startsWith: {value: txt}}; + case 'substring': + return {containsSubstring: {value: txt, negated: !!event.negate}}; + case 'not_substring': + return {containsSubstring: {value: txt, negated: true}}; + case 'regex': + return {matchesRegex: {value: txt, negated: !!event.negate}}; + case 'not_regex': + return {matchesRegex: {value: txt, negated: true}}; + case 'exactly': + return {matchesExactly: {values: event.advValues || (txt ? [txt] : [])}}; + case 'at_least': + return {matchesAtLeast: {values: event.advValues || (txt ? [txt] : [])}}; + default: + return undefined; + } +} + +/** Formats fallback human-readable pill condition string from Popover ValuePickerApplyEvent. */ +export function formatPillConditionFromEvent( + event: ValuePickerApplyEvent, +): string { + if (event.isAdvanced) { + const modeObj = ADV_MODES_LIST.find((m) => m.id === event.advMode); + const modeLabel = modeObj?.label || event.advMode || ''; + const valStr = event.advValues?.length + ? event.advValues.join(', ') + : event.advText || ''; + return `${modeLabel} "${valStr}"`; + } + + if (event.rangeFrom || event.rangeTo) { + return `${event.rangeFrom || ''} ~ ${event.rangeTo || ''}`.trim(); + } + + if (event.selected && event.selected.length > 0) { + return event.selected.join(', '); + } + + if (event.propName || event.textVal) { + return `${event.propName || ''}:${event.textVal || ''}`; + } + + if (event.textVal?.trim()) { + return event.textVal.trim(); + } + + return ''; +} + +/** Helper to check if a ValuePickerApplyEvent payload contains empty selection values. */ +export function isValuePickerSelectionEmpty( + event: ValuePickerApplyEvent, +): boolean { + if (event.isAdvanced) { + if (event.advMode === 'exactly' || event.advMode === 'at_least') { + return !event.advValues?.length; + } + return !event.advText?.trim(); + } + return ( + !event.selected?.length && + !event.rangeFrom?.trim() && + !event.rangeTo?.trim() && + !event.textVal?.trim() && + !event.propName?.trim() + ); +} + +// ============================================================================ +// Simple Fleet Filter Builders +// ============================================================================ + +/** Constructs simple Fleet Filter Protobuf payload. */ +export function buildSimpleFleetFilter( + key: string, + values: string[], + negated?: boolean, +): Filter { + const isNoVal = values.includes(''); + const cleanVals = values.filter((v) => v !== ''); + + return { + key, + simple: { + values: [ + ...cleanVals.map((v) => ({value: v})), + ...(isNoVal ? [{noValue: true}] : []), + ], + negated: !!negated, + }, + }; +} + +// ============================================================================ +// URL Filter String Serializer & Parser +// ============================================================================ + +/** Helper to extract unique values from a ComplexMatch object. */ +function getValuesFromComplexMatch(complex: ComplexMatch): string[] { + if (complex.startsWith?.value) { + return [complex.startsWith.value]; + } + if (complex.containsSubstring?.value) { + return [complex.containsSubstring.value]; + } + if (complex.matchesRegex?.value) { + return [complex.matchesRegex.value]; + } + if (complex.matchesExactly?.values) { + return complex.matchesExactly.values; + } + if (complex.matchesAtLeast?.values) { + return complex.matchesAtLeast.values; + } + return []; +} + + +/** Formats a ComplexMatch object into a canonical, parser-friendly string representation. */ +function getCanonicalComplexCondition(complex: ComplexMatch): string { + if (complex.startsWith?.value) { + return `Starts with "${complex.startsWith.value}"`; + } + if (complex.containsSubstring?.value) { + const term = complex.containsSubstring.value; + return complex.containsSubstring.negated + ? `Does not contain "${term}"` + : `Contains "${term}"`; + } + if (complex.matchesRegex?.value) { + const term = complex.matchesRegex.value; + return complex.matchesRegex.negated + ? `Does not match regex "${term}"` + : `Matches regex "${term}"`; + } + if (complex.matchesExactly?.values?.length) { + const vals = complex.matchesExactly.values.join(', '); + return `Is exactly "${vals}"`; + } + if (complex.matchesAtLeast?.values?.length) { + const vals = complex.matchesAtLeast.values.join(', '); + return `Is at least "${vals}"`; + } + return ''; +} + +/** Serializes a FilterChip object into a compressed URL query string format. */ +export function serializeFilterChip(c: FilterChip): string { + if (c.isGroupBy) { + return ''; + } + + const prefix = c.negated ? '!' : ''; + const key = c.key || c.pillKey; + + if (c.complex) { + const cond = getCanonicalComplexCondition(c.complex); + if (cond) { + return `${prefix}${key}~${cond}`; + } + } + + if (c.rawValues && c.rawValues.length > 0) { + return `${prefix}${key}~${c.rawValues.join(',')}`; + } + + if (c.pillCondition) { + return `${prefix}${key}~${c.pillCondition}`; + } + + return `${prefix}${key}`; +} + +/** Parses a URL filter param string (e.g. "-status~RUNNING") into a ParsedQueryFilter object. */ +export function parseQueryFilterParam( + rawParam: string, +): ParsedQueryFilter | null { + if (!rawParam || typeof rawParam !== 'string') return null; + + let str = rawParam.trim(); + let negated = false; + + if (str.startsWith('-') || str.startsWith('!')) { + negated = true; + str = str.substring(1).trim(); + } + + // Support multiple filter separators: '~' (primary), '=' or ':' (compatibility) + let separatorIdx = str.indexOf('~'); + if (separatorIdx === -1) { + separatorIdx = str.indexOf('='); + } + if (separatorIdx === -1) { + separatorIdx = str.indexOf(':'); + } + + let key = str; + let rawCond = ''; + + if (separatorIdx !== -1) { + key = str.substring(0, separatorIdx).trim(); + rawCond = str.substring(separatorIdx + 1).trim(); + } + + if (!key) return null; + + const rawValues = rawCond + ? rawCond + .split(',') + .map((v) => v.trim()) + .filter(Boolean) + : []; + + const complexMatch = parseComplexCondition(rawCond, rawValues, negated); + + const finalRawValues = complexMatch + ? getValuesFromComplexMatch(complexMatch) + : rawValues; + + const fleetFilter: Filter = complexMatch + ? {key, complex: complexMatch} + : buildSimpleFleetFilter(key, finalRawValues, negated); + + const tjsFilter: TjsFilter = complexMatch + ? {key, stringValue: {value: rawCond}} + : finalRawValues.length > 0 + ? {key, stringValue: {value: finalRawValues.join(',')}} + : {key, stringValue: {value: rawCond}}; + + return { + key, + filter: fleetFilter, + tjsFilter, + fallbackPillCondition: rawCond || key, + rawValues: finalRawValues.length > 0 ? finalRawValues : undefined, + negated, + complex: complexMatch, + }; +} + +/** Converts a FilterChip to a Fleet Filter Protobuf payload. */ +export function buildFleetFilterFromChip(c: FilterChip): Filter { + const key = c.key || c.pillKey; + if (c.complex) { + return {key, complex: c.complex}; + } + + const parsedComplex = parseComplexCondition( + c.pillCondition, + c.rawValues, + c.negated, + ); + if (parsedComplex) { + return {key, complex: parsedComplex}; + } + + const rawVals = c.rawValues?.length + ? c.rawValues + : c.pillCondition.split(','); + + return buildSimpleFleetFilter(key, rawVals, c.negated); +} + +// ============================================================================ +// Chip Dimension & Key Comparison (Deduplication) +// ============================================================================ + +/** Strips known filter key prefixes ('field::', 'dim::', 'config::') and lowercases the key. */ +export function normalizeKey(key: string | null | undefined): string { + if (!key) return ''; + return key.toLowerCase().replace(/^(field::|dim::|config::)/, ''); +} + +/** Extracts a normalized lower-case search key for chip comparison. */ +export function getNormalizedChipKey(c: FilterChip): string { + const rawKey = c.isGroupBy ? c.pillKey : c.key || c.pillKey; + return normalizeKey(rawKey); +} + +/** Helper to compare whether two filter chips refer to the same logical search key. */ +export function isSameFilterChip(a: FilterChip, b: FilterChip): boolean { + if (a === b) return true; + if (!!a.isGroupBy !== !!b.isGroupBy) return false; + return getNormalizedChipKey(a) === getNormalizedChipKey(b); +} + +/** Normalizes Angular Router query param value (string or string[]) into a clean array of strings. */ +export function getQueryParamAsArray(rawParam: unknown): string[] { + if (Array.isArray(rawParam)) { + return rawParam.map(String).filter(Boolean); + } + if (typeof rawParam === 'string' && rawParam) { + return [rawParam]; + } + return []; +} + +/** Builds a normalized canonical key string representing URL query parameters for comparison and deduplication. */ +export function buildUrlParamKey( + filters: string[], + groupBys: string[], + fleet?: string | null, +): string { + const normFilters = Array.from(filters, (s) => s.toLowerCase().trim()) + .filter(Boolean) + .sort() + .join('&'); + const normGb = Array.from(groupBys, (s) => s.toLowerCase().trim()) + .filter(Boolean) + .sort() + .join(','); + const normFleet = + fleet && fleet.toLowerCase().trim() !== 'internal' + ? fleet.toLowerCase().trim() + : ''; + return `f=${normFilters}|gb=${normGb}|fleet=${normFleet}`; +} + +/** Maps a raw suggestion object into a unified SearchBoxSuggestion structure. */ +export function mapToSearchBoxSuggestion( + item: FleetSuggestion | TjsSuggestion, +): SearchBoxSuggestion { + const fleetItem = item as FleetSuggestion; + return { + label: item.label, + mainText: item.mainText, + count: fleetItem.count, + countPrefix: fleetItem.countPrefix, + countUnit: fleetItem.countUnit, + overMax: fleetItem.overMax, + rawItem: item, + }; +} + + +/** + * Builds a FleetGroupSort Protobuf payload from a frontend sort value string and active group-by keys. + * Supports 'count:desc', 'count:asc', 'gb_asc:', 'gb_desc:', 'name:asc', and 'name:desc'. + * Preserves exact group keys containing colons (e.g. 'host::lab_type'). + */ +export function buildFleetGroupSort( + sortStr: string, + groupByKeys: string[], +): FleetGroupSort | undefined { + if (!sortStr) return undefined; + + if (sortStr === 'count:desc') { + return {field: {itemCount: {}}, ascending: false}; + } + if (sortStr === 'count:asc') { + return {field: {itemCount: {}}, ascending: true}; + } + + if (sortStr.startsWith('gb_asc:')) { + const key = sortStr.slice('gb_asc:'.length); + if (key) return {field: {groupKey: key}, ascending: true}; + } + + if (sortStr.startsWith('gb_desc:')) { + const key = sortStr.slice('gb_desc:'.length); + if (key) return {field: {groupKey: key}, ascending: false}; + } + + if (sortStr === 'name:asc' && groupByKeys.length > 0) { + return {field: {groupKey: groupByKeys[0]}, ascending: true}; + } + + if (sortStr === 'name:desc' && groupByKeys.length > 0) { + return {field: {groupKey: groupByKeys[0]}, ascending: false}; + } + + // Fallback for direct field key (e.g. 'host::lab_type:asc') + const colonIdx = sortStr.lastIndexOf(':'); + if (colonIdx > 0) { + const key = sortStr.substring(0, colonIdx); + const dir = sortStr.substring(colonIdx + 1); + if (groupByKeys.includes(key)) { + return {field: {groupKey: key}, ascending: dir === 'asc'}; + } + } + + return undefined; +} + +/** Extracts a FilterChip representation from a selected FleetSuggestion item. */ +export function extractFilterChipFromFleetSuggestion( + item: FleetSuggestion, +): FilterChip | null { + if (!item.applyFilter) return null; + const af = item.applyFilter; + const rf = af.resultingFilter; + const rawVals = rf?.simple?.values + ?.map((v) => v.value || (v.noValue ? '' : '')) + .filter(Boolean); + const isNegated = rf?.simple?.negated || false; + return { + key: rf?.key, + pillKey: af.pillKey || 'Filter', + pillCondition: af.pillCondition || '', + isGroupBy: false, + metadata: af.metadata, + rawValues: rawVals && rawVals.length > 0 ? rawVals : undefined, + negated: isNegated, + complex: rf?.complex, + }; +} + +/** Extracts a FilterChip representation from a selected TjsSuggestion item. */ +export function extractFilterChipFromTjsSuggestion( + item: TjsSuggestion, +): FilterChip | null { + if (!item.applyFilter) return null; + const af = item.applyFilter; + let rawVals: string[] | undefined; + if (af.filter?.stringValue?.value) { + rawVals = [af.filter.stringValue.value]; + } else if ( + af.filter?.enumValues?.values && + af.filter.enumValues.values.length > 0 + ) { + rawVals = af.filter.enumValues.values; + } else if (af.filter?.namedValue) { + rawVals = [ + af.filter.namedValue.name || '', + af.filter.namedValue.value || '', + ].filter(Boolean); + } + return { + key: af.filter?.key, + pillKey: af.pillKey || af.keyDisplayName || 'Filter', + pillCondition: af.pillCondition || '', + isGroupBy: false, + rawValues: rawVals && rawVals.length > 0 ? rawVals : undefined, + }; +} + diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_pipes.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_pipes.ts new file mode 100644 index 0000000000..085a1d9a13 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/search_pipes.ts @@ -0,0 +1,82 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {Cell, Column, Row} from '../../../core/models/search'; +import {FilterChip} from '../models'; +import {getCell} from './search_cell_utils'; + +/** + * Pure pipe to extract a Cell from a Row given column key and columns catalog. + */ +@Pipe({ + name: 'appCell', + standalone: true, + pure: true, +}) +export class SearchCellPipe implements PipeTransform { + transform( + row: Row | Record | undefined, + colKey: string, + columns: Column[], + ): Cell | null { + return getCell(row, colKey, columns); + } +} + +/** + * Pure pipe to calculate rounded utilization percentage. + */ +@Pipe({ + name: 'appUtilPct', + standalone: true, + pure: true, +}) +export class UtilPctPipe implements PipeTransform { + transform(val: number | undefined, total: number | undefined): number { + if (!val || !total || total <= 0) return 0; + return Math.round((100 * val) / total); + } +} + +/** + * Pure pipe to calculate width percentage for utilization bar. + */ +@Pipe({ + name: 'appUtilWidth', + standalone: true, + pure: true, +}) +export class UtilWidthPipe implements PipeTransform { + transform(val: number | undefined, total: number | undefined): number { + if (!val || !total || total <= 0) return 0; + return (100 * val) / total; + } +} + +/** + * Helper to check whether a FilterChip is negated. + */ +export function isChipNegated(chip: FilterChip): boolean { + if (chip.negated) return true; + if (chip.complex?.containsSubstring?.negated) return true; + if (chip.complex?.matchesRegex?.negated) return true; + const cond = (chip.pillCondition || '').toLowerCase().trim(); + return ( + cond.startsWith('does not') || + cond.startsWith('is not') || + cond.startsWith('not ') || + cond.startsWith('!') + ); +} + +/** + * Pure pipe to determine if a FilterChip is negated. + */ +@Pipe({ + name: 'appChipNegated', + standalone: true, + pure: true, +}) +export class ChipNegatedPipe implements PipeTransform { + transform(chip: FilterChip): boolean { + return isChipNegated(chip); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/value_picker_utils.spec.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/value_picker_utils.spec.ts new file mode 100644 index 0000000000..d7ac0cdc10 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/value_picker_utils.spec.ts @@ -0,0 +1,212 @@ +import {PickerValueItem} from '../models/value_picker_models'; +import { + buildDisplayValues, + buildValuePickerApplyEvent, + computeFilteredAndSortedValues, + computePinnedValues, + parseDateRange, + toDateTimeLocalString, +} from './value_picker_utils'; + +describe('value_picker_utils', () => { + const mockItems: PickerValueItem[] = [ + {value: 'apple', displayLabel: 'Apple', filtered: 10, total: 100}, + {value: 'banana', displayLabel: 'Banana', filtered: 50, total: 50}, + {value: 'cherry', displayLabel: 'Cherry', filtered: 5, total: 200}, + {value: 'date', displayLabel: 'Date', filtered: 0, total: 10, disabled: true}, + ]; + + describe('buildDisplayValues', () => { + it('should return base items if no staged inputs', () => { + const result = buildDisplayValues(mockItems, new Set()); + expect(result.length).toBe(4); + }); + + it('should add missing staged inputs as disabled items', () => { + const staged = new Set(['grape', 'apple']); + const result = buildDisplayValues(mockItems, staged, false); + expect(result.length).toBe(5); + const grape = result.find((i) => i.value === 'grape'); + expect(grape).toBeDefined(); + expect(grape?.disabled).toBeTrue(); + }); + + it('should return base items when loading even with staged inputs', () => { + const staged = new Set(['grape']); + const result = buildDisplayValues(mockItems, staged, true); + expect(result.length).toBe(4); + }); + }); + + describe('computeFilteredAndSortedValues', () => { + it('should filter items by query matching label or value', () => { + const result = computeFilteredAndSortedValues({ + items: mockItems, + query: 'an', + sortBy: 'value', + sortAsc: true, + }); + expect(result.map((i) => i.value)).toEqual(['banana']); + }); + + it('should sort by value asc and desc', () => { + const asc = computeFilteredAndSortedValues({ + items: mockItems, + query: '', + sortBy: 'value', + sortAsc: true, + }); + // Normal items sorted by label asc, disabled item 'date' sinks to bottom + expect(asc.map((i) => i.value)).toEqual(['apple', 'banana', 'cherry', 'date']); + + const desc = computeFilteredAndSortedValues({ + items: mockItems, + query: '', + sortBy: 'value', + sortAsc: false, + }); + expect(desc.map((i) => i.value)).toEqual(['cherry', 'banana', 'apple', 'date']); + }); + + it('should sort by filtered count desc and asc', () => { + const desc = computeFilteredAndSortedValues({ + items: mockItems, + query: '', + sortBy: 'filtered', + sortAsc: false, + }); + expect(desc.map((i) => i.value)).toEqual(['banana', 'apple', 'cherry', 'date']); + + const asc = computeFilteredAndSortedValues({ + items: mockItems, + query: '', + sortBy: 'filtered', + sortAsc: true, + }); + expect(asc.map((i) => i.value)).toEqual(['cherry', 'apple', 'banana', 'date']); + }); + + it('should sort by total count desc', () => { + const desc = computeFilteredAndSortedValues({ + items: mockItems, + query: '', + sortBy: 'total', + sortAsc: false, + }); + expect(desc.map((i) => i.value)).toEqual(['cherry', 'apple', 'banana', 'date']); + }); + }); + + describe('computePinnedValues', () => { + it('should return empty array if total count is under threshold', () => { + const selected = new Set(['apple']); + const result = computePinnedValues(mockItems, selected, '', false, 20); + expect(result).toEqual([]); + }); + + it('should return sorted pinned items if count exceeds threshold and has selections', () => { + const largeList: PickerValueItem[] = Array.from({length: 25}, (_, idx) => ({ + value: `item_${idx}`, + displayLabel: `Label ${idx.toString().padStart(2, '0')}`, + })); + const selected = new Set(['item_5', 'item_2']); + const result = computePinnedValues(largeList, selected, '', false, 20); + expect(result.map((i) => i.value)).toEqual(['item_2', 'item_5']); + }); + + it('should return empty if query is active or loading', () => { + const largeList: PickerValueItem[] = Array.from({length: 25}, (_, idx) => ({ + value: `item_${idx}`, + displayLabel: `Label ${idx}`, + })); + const selected = new Set(['item_5']); + expect(computePinnedValues(largeList, selected, 'some query', false, 20)).toEqual([]); + expect(computePinnedValues(largeList, selected, '', true, 20)).toEqual([]); + }); + }); + + describe('toDateTimeLocalString & parseDateRange', () => { + it('should format ms to local datetime string format', () => { + const date = new Date(2026, 0, 15, 14, 30); + const str = toDateTimeLocalString(date.getTime()); + expect(str).toBe('2026-01-15T14:30'); + }); + + it('should parse range string correctly', () => { + const set = new Set(['From: 2026-01-01T00:00 To: 2026-01-02T00:00']); + const parsed = parseDateRange(set); + expect(parsed.from).toBe('2026-01-01T00:00'); + expect(parsed.to).toBe('2026-01-02T00:00'); + }); + }); + + describe('buildValuePickerApplyEvent', () => { + it('should construct range event', () => { + const event = buildValuePickerApplyEvent({ + type: 'range', + isAdvanced: false, + negated: false, + selectedSet: new Set(), + searchQuery: '', + rangeFrom: '2026-01-01T00:00', + rangeTo: '2026-01-02T00:00', + }); + expect(event).toEqual({ + selected: ['From: 2026-01-01T00:00 To: 2026-01-02T00:00'], + negate: false, + isAdvanced: false, + rangeFrom: '2026-01-01T00:00', + rangeTo: '2026-01-02T00:00', + }); + }); + + it('should construct namedPair event', () => { + const event = buildValuePickerApplyEvent({ + type: 'namedPair', + isAdvanced: false, + negated: false, + selectedSet: new Set(), + searchQuery: '', + propName: 'device_type', + propVal: 'pixel', + }); + expect(event).toEqual({ + selected: ['pixel'], + negate: false, + isAdvanced: false, + propName: 'device_type', + textVal: 'pixel', + }); + }); + + it('should construct advanced event', () => { + const event = buildValuePickerApplyEvent({ + isAdvanced: true, + negated: false, + selectedSet: new Set(), + searchQuery: '', + advMode: 'prefix', + advText: 'test_prefix', + }); + expect(event).toEqual({ + selected: [], + negate: false, + isAdvanced: true, + advMode: 'prefix', + advText: 'test_prefix', + advValues: undefined, + }); + }); + + it('should include pending search query if not already in selected list', () => { + const event = buildValuePickerApplyEvent({ + isAdvanced: false, + negated: true, + selectedSet: new Set(['item1']), + searchQuery: 'item2', + }); + expect(event.selected).toEqual(['item1', 'item2']); + expect(event.negate).toBeTrue(); + }); + }); +}); diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/value_picker_utils.ts b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/value_picker_utils.ts new file mode 100644 index 0000000000..322ec35475 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/search_page/utils/value_picker_utils.ts @@ -0,0 +1,260 @@ +import { + AdvancedMatchMode, + PickerValueItem, + ValuePickerApplyEvent, +} from '../models/value_picker_models'; + +/** Configuration options passed to computeFilteredAndSortedValues pure engine function. */ +export interface FilterSortOptions { + /** Candidate value items available for selection. */ + readonly items: readonly PickerValueItem[]; + /** User-entered text query for filtering candidate items. */ + readonly query: string; + /** Primary column to sort items by ('value' | 'filtered' | 'total'). */ + readonly sortBy: 'value' | 'filtered' | 'total'; + /** Sort direction flag (true = ascending, false = descending). */ + readonly sortAsc: boolean; + /** Staged custom user inputs added manually that are not in base items. */ + readonly stagedCustomInputs?: ReadonlySet; + /** Loading flag indicating whether backend values are currently fetching. */ + readonly isLoading?: boolean; +} + +/** + * Pure function: Combines base candidate items with staged custom free-text inputs. + * + * @param baseValues List of candidate items returned by backend or configuration. + * @param staged Set of custom inputs entered by user. + * @param isLoading Whether candidate values are currently loading. + * @returns Array of combined PickerValueItem elements. + */ +export function buildDisplayValues( + baseValues: readonly PickerValueItem[], + staged: ReadonlySet | undefined, + isLoading = false, +): PickerValueItem[] { + + if (isLoading || !staged || staged.size === 0) { + return [...baseValues]; + } + + const baseLower = new Set( + baseValues.flatMap((v) => [ + v.value.toLowerCase(), + v.displayLabel.toLowerCase(), + ]), + ); + + const missingStaged: PickerValueItem[] = []; + for (const customVal of staged) { + if (!baseLower.has(customVal.toLowerCase())) { + missingStaged.push({ + value: customVal, + displayLabel: customVal, + disabled: true, + }); + } + } + + return [...baseValues, ...missingStaged]; +} + +/** + * Pure function: Filters and multi-column sorts candidate items for display in the picker UI. + * + * @param opts Options specifying items, search query, sort column, and sort direction. + * @returns Filtered and sorted slice of candidate items capped at 100 elements. + */ +export function computeFilteredAndSortedValues( + opts: FilterSortOptions, +): PickerValueItem[] { + const displayItems = buildDisplayValues( + opts.items, + opts.stagedCustomInputs, + opts.isLoading, + ); + const q = opts.query.toLowerCase().trim(); + + const list = !q + ? displayItems + : displayItems.filter( + (v) => + v.value.toLowerCase().includes(q) || + v.displayLabel.toLowerCase().includes(q), + ); + + const normalItems = list.filter((v) => !v.disabled); + const disabledItems = list.filter((v) => v.disabled); + + normalItems.sort((a, b) => { + if (opts.sortBy === 'value') { + const res = a.displayLabel + .toLowerCase() + .localeCompare(b.displayLabel.toLowerCase()); + return opts.sortAsc ? res : -res; + } + + const valA = (opts.sortBy === 'filtered' ? a.filtered : a.total) ?? 0; + const valB = (opts.sortBy === 'filtered' ? b.filtered : b.total) ?? 0; + return opts.sortAsc ? valA - valB : valB - valA; + }); + + return [...normalItems, ...disabledItems].slice(0, 100); +} + +/** + * Pure function: Computes pinned selected items when total candidate count exceeds threshold. + * + * @param items List of candidate items. + * @param selectedSet Set of currently selected value strings. + * @param query Active search query. + * @param isLoading Whether values are currently loading. + * @param threshold Maximum total item threshold before pinning triggers (default 20). + * @returns Array of pinned items matching current selections. + */ +export function computePinnedValues( + items: readonly PickerValueItem[], + selectedSet: ReadonlySet, + query: string, + isLoading: boolean, + threshold = 20, +): PickerValueItem[] { + + const hasQuery = query.trim().length > 0; + if (hasQuery || isLoading || items.length <= threshold || selectedSet.size === 0) { + return []; + } + + return items + .filter((v) => selectedSet.has(v.value)) + .sort((a, b) => a.displayLabel.localeCompare(b.displayLabel)); +} + +/** + * Pure function: Formats millisecond timestamp into datetime-local HTML input format YYYY-MM-DDTHH:MM. + * + * @param ms Epoch timestamp in milliseconds. + * @returns Formatted datetime-local string. + */ +export function toDateTimeLocalString(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** + * Pure function: Extracts From and To date strings from selected values set or returns default 24h range. + * + * @param selectedValues Set of selected value strings. + * @returns Object containing 'from' and 'to' datetime-local strings. + */ +export function parseDateRange( + selectedValues: ReadonlySet, +): {from: string; to: string} { + const selected = Array.from(selectedValues); + const t = selected[0] || ''; + const match = t.match(/From:\s*([^\s]+)\s*To:\s*([^\s]+)/); + if (match) { + return {from: match[1], to: match[2]}; + } + + const now = Date.now(); + return { + from: toDateTimeLocalString(now - 86400000), + to: toDateTimeLocalString(now), + }; +} + +/** Input payload interface for buildValuePickerApplyEvent. */ +export interface ApplyEventPayload { + /** Specific picker mode type ('range', 'namedPair', 'text', etc.). */ + readonly type?: string; + /** Whether advanced regex/match mode is active. */ + readonly isAdvanced: boolean; + /** Whether negate/polarity toggle is active. */ + readonly negated: boolean; + /** Set of selected value strings. */ + readonly selectedSet: ReadonlySet; + /** Pending search query in input box. */ + readonly searchQuery: string; + /** Advanced match mode string ('prefix', 'suffix', 'contains', 'regex', etc.). */ + readonly advMode?: AdvancedMatchMode; + /** Advanced mode text query. */ + readonly advText?: string; + /** Advanced mode values array. */ + readonly advValues?: readonly string[]; + /** From date string for range mode. */ + readonly rangeFrom?: string; + /** To date string for range mode. */ + readonly rangeTo?: string; + /** Property name for namedPair mode. */ + readonly propName?: string; + /** Property value for namedPair mode. */ + readonly propVal?: string; + /** Text value for plain text mode. */ + readonly textVal?: string; +} + +/** + * Pure function: Constructs canonical ValuePickerApplyEvent payload for store application. + * + * @param payload Event payload state parameters. + * @returns ValuePickerApplyEvent object ready to dispatch. + */ +export function buildValuePickerApplyEvent( + payload: ApplyEventPayload, +): ValuePickerApplyEvent { + const {type, isAdvanced, negated, selectedSet, searchQuery} = payload; + + switch (type) { + case 'range': { + const fromVal = (payload.rangeFrom || '').trim(); + const toVal = (payload.rangeTo || '').trim(); + return { + selected: [`From: ${fromVal} To: ${toVal}`], + negate: false, + isAdvanced: false, + rangeFrom: fromVal, + rangeTo: toVal, + }; + } + case 'namedPair': { + const name = (payload.propName || '').trim(); + const val = (payload.propVal || '').trim(); + return { + selected: [val], + negate: false, + isAdvanced: false, + propName: name, + textVal: val, + }; + } + case 'text': { + const val = (payload.textVal || '').trim(); + return { + selected: [val], + negate: false, + isAdvanced: false, + textVal: val, + }; + } + default: + break; + } + + + const selectedList = Array.from(selectedSet); + const pendingQuery = searchQuery.trim(); + if (pendingQuery && !selectedList.includes(pendingQuery) && !isAdvanced) { + selectedList.push(pendingQuery); + } + + return { + selected: selectedList, + negate: negated, + isAdvanced, + advMode: isAdvanced ? payload.advMode : undefined, + advText: isAdvanced ? (payload.advText || '').trim() : undefined, + advValues: isAdvanced && payload.advValues ? [...payload.advValues] : undefined, + }; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/features/session_detail/session_detail.scss b/src/devtools/mobileharness/fe/v6/angular/app/features/session_detail/session_detail.scss index 34cc33fff7..8875837c49 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/features/session_detail/session_detail.scss +++ b/src/devtools/mobileharness/fe/v6/angular/app/features/session_detail/session_detail.scss @@ -1,10 +1,15 @@ @use '../../shared/styles/overview'; +@use '../../shared/styles/common'; :host { display: block; - min-height: 100%; + height: 100%; + min-height: 0; + overflow-y: auto; + box-sizing: border-box; padding: 1.5rem; font-family: overview.$labconsole-font; + @include common.shared-scrollbar-style(); } .session-detail-container { diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.ng.html new file mode 100644 index 0000000000..ae4ac73ef5 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.ng.html @@ -0,0 +1,35 @@ +@if (normalizedItems().length > 0) { +
+ @for (item of displayedItems(); track $index) { + {{ item.label }} + } + @if (remainingCount() > 0) { + + } +
+} @else { + - +} + +@if (activeOverlay(); as overlay) { + +
+ +
+
+} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.scss b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.scss new file mode 100644 index 0000000000..572a6ef649 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.scss @@ -0,0 +1,62 @@ +:host { + display: inline-flex; + align-items: center; +} + +.chip-list-wrapper, +.chip-wrapper { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.chip-item, +.chip { + display: inline-block; + padding: 2px 8px; + border-radius: 8px; + background-color: #e0e0e0; + color: #3c4043; + font-size: 0.8rem; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} + +.chip-normal, +.chip-default { + background-color: #e0e0e0; + color: #3c4043; +} + +.chip-abnormal, +.chip-error { + background-color: #fce8e6; + color: #c5221f; +} + +.chip-more { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 6px; + font-size: 0.8rem; + line-height: 1; + white-space: nowrap; + border: 1px solid #e0e0e0; + color: #3c4043; + font-family: inherit; + margin-left: 4px; + background-color: transparent; + cursor: pointer; + + &:hover { + background-color: #e9ecef; + } +} + +.empty-chip { + color: #747775; +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.ts b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.ts new file mode 100644 index 0000000000..2a32d3a31f --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_chip_list/overflow_chip_list.ts @@ -0,0 +1,198 @@ +import { + CdkOverlayOrigin, + ConnectedPosition, + OverlayModule, +} from '@angular/cdk/overlay'; +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + signal, +} from '@angular/core'; +import { + SearchableListOverlayComponent, + SearchableListOverlayData, +} from '../searchable_list_overlay/searchable_list_overlay'; + +/** Item descriptor for OverflowChipListComponent. */ +export interface OverflowChipItem { + label?: string; + type?: string; + value?: string; + name?: string; + cssClass?: string; + isAbnormal?: boolean; +} + +/** + * Reusable component for displaying chips with an interactive "+N more" overflow button + * that triggers a searchable overlay list. + */ +@Component({ + selector: 'app-overflow-chip-list', + standalone: true, + imports: [CommonModule, OverlayModule, SearchableListOverlayComponent], + templateUrl: './overflow_chip_list.ng.html', + styleUrl: './overflow_chip_list.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OverflowChipListComponent { + readonly items = input.required>(); + readonly limit = input(1); + readonly title = input('Items'); + readonly subtitle = input(''); + readonly defaultChipClass = input('chip-normal'); + + readonly activeOverlay = signal<{ + origin: CdkOverlayOrigin; + data: SearchableListOverlayData; + } | null>(null); + + readonly overlayPositions: ConnectedPosition[] = [ + // Priority 1: Right-aligned below + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + offsetY: 8, + }, + // Priority 1: Right-aligned above + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + offsetY: -8, + }, + // Priority 2: Left-aligned below + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + // Priority 2: Left-aligned above + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, + // Fallback: Centered below + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8, + }, + // Fallback: Centered above + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8, + }, + // Fallback: Open to the left of the origin (button on the right of overlay) + { + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + offsetY: 8, + }, + // Fallback: Open to the right of the origin (button on the left of overlay) + { + originX: 'end', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + ]; + + readonly normalizedItems = computed(() => { + const raw = this.items() || []; + return raw.map((item) => { + if (typeof item === 'string') { + return { + label: item, + cssClass: this.defaultChipClass(), + }; + } + if (item && typeof item === 'object') { + const label = + item.label || item.type || item.value || item.name || String(item); + const cssClass = + item.cssClass || + (item.isAbnormal ? 'chip-abnormal' : this.defaultChipClass()); + return {label, cssClass}; + } + return {label: String(item), cssClass: this.defaultChipClass()}; + }); + }); + + readonly displayedItems = computed(() => { + return this.normalizedItems().slice(0, this.limit()); + }); + + readonly remainingCount = computed(() => { + return Math.max(0, this.normalizedItems().length - this.limit()); + }); + + private hideTimer: ReturnType | null = null; + + openOverlay(trigger: CdkOverlayOrigin) { + this.clearHideTimer(); + this.activeOverlay.set({ + origin: trigger, + data: { + title: this.title() || 'Items', + subtitle: this.subtitle(), + type: 'chip', + items: this.normalizedItems(), + }, + }); + } + + onMoreHover(trigger: CdkOverlayOrigin) { + this.openOverlay(trigger); + } + + onMoreLeave(event?: MouseEvent) { + this.scheduleHideOverlay(); + } + + onOverlayEnter() { + this.clearHideTimer(); + } + + onOverlayLeave(event?: MouseEvent) { + this.scheduleHideOverlay(); + } + + closeOverlay() { + this.clearHideTimer(); + this.activeOverlay.set(null); + } + + private clearHideTimer() { + if (this.hideTimer) { + clearTimeout(this.hideTimer); + this.hideTimer = null; + } + } + + private scheduleHideOverlay() { + this.clearHideTimer(); + this.hideTimer = setTimeout(() => { + this.activeOverlay.set(null); + }, 200); + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.ng.html deleted file mode 100644 index 8838be898d..0000000000 --- a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.ng.html +++ /dev/null @@ -1,15 +0,0 @@ -
- @for (item of displayedItems(); track $index) { - - } - @if (remainingCount() > 0) { - - } -
diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.scss b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.scss deleted file mode 100644 index 4fe0fe80f6..0000000000 --- a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.scss +++ /dev/null @@ -1,26 +0,0 @@ -.chip-wrapper { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; -} - -.chip-more { - display: inline-flex; - align-items: center; - padding: 4px 10px; - border-radius: 6px; - font-size: 0.8rem; - font-weight: 500; - line-height: 1; - white-space: nowrap; - cursor: pointer; - border: 1px solid #e0e0e0; - color: #3c4043; - font-family: inherit; - background-color: transparent; - - &:hover { - background-color: #e9ecef; - } -} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.ts b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.ts deleted file mode 100644 index c668461b0b..0000000000 --- a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/overflow_list/overflow_list.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {CdkOverlayOrigin, OverlayModule} from '@angular/cdk/overlay'; -import {CommonModule} from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - computed, - EventEmitter, - Input, - Output, - TemplateRef, -} from '@angular/core'; - -/** - * Component to display a list of items with a limit and a "more" button for overflow. - */ -@Component({ - selector: 'app-overflow-list', - standalone: true, - imports: [CommonModule, OverlayModule], - templateUrl: './overflow_list.ng.html', - styleUrl: './overflow_list.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class OverflowList { - @Input({required: true}) items: T[] = []; - @Input() limit = 1; - @Input({required: true}) itemTemplate!: TemplateRef; - @Input() moreButtonClass = ''; - - @Output() readonly showOverlay = new EventEmitter(); - @Output() readonly hideOverlay = new EventEmitter(); - - readonly itemsSignal = computed(() => this.items || []); - - readonly displayedItems = computed(() => { - return this.itemsSignal().slice(0, this.limit); - }); - - readonly remainingCount = computed(() => { - return Math.max(0, this.itemsSignal().length - this.limit); - }); - - onMoreHover(trigger: CdkOverlayOrigin) { - this.showOverlay.emit(trigger); - } - - onMoreLeave(event: MouseEvent) { - this.hideOverlay.emit(event); - } -} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/searchable_list_overlay/searchable_list_overlay.ng.html b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/searchable_list_overlay/searchable_list_overlay.ng.html index 33a4dad0bb..0263ffb27d 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/searchable_list_overlay/searchable_list_overlay.ng.html +++ b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/searchable_list_overlay/searchable_list_overlay.ng.html @@ -20,7 +20,7 @@

All {{ data.title }} @if(data.subtitle) { for {{data.subtitle} @for (item of filteredItems(); track $index) { @if (data.type === 'chip') { @let chip = $any(item); -
+
{{chip.label}}
} @else if (data.type === 'simple') { diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/searchable_list_overlay/searchable_list_overlay.scss b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/searchable_list_overlay/searchable_list_overlay.scss index 7f790e9870..7abe10d84e 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/shared/components/searchable_list_overlay/searchable_list_overlay.scss +++ b/src/devtools/mobileharness/fe/v6/angular/app/shared/components/searchable_list_overlay/searchable_list_overlay.scss @@ -95,6 +95,30 @@ flex-wrap: wrap; align-content: flex-start; gap: 8px; + + .chip-item, + .chip { + display: inline-block; + padding: 4px 10px; + border-radius: 8px; + background-color: #e0e0e0; + color: #3c4043; + font-size: 0.8rem; + overflow: hidden; + vertical-align: middle; + + &.chip-normal, + &.chip-default { + background-color: #e0e0e0; + color: #3c4043; + } + + &.chip-abnormal, + &.chip-error { + background-color: #fce8e6; + color: #c5221f; + } + } } } diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/styles/search_table_common.scss b/src/devtools/mobileharness/fe/v6/angular/app/shared/styles/search_table_common.scss new file mode 100644 index 0000000000..ea214bd095 --- /dev/null +++ b/src/devtools/mobileharness/fe/v6/angular/app/shared/styles/search_table_common.scss @@ -0,0 +1,279 @@ +@use '@angular/material' as mat; +@use 'common'; + +@mixin search-table-base { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + min-height: 0; + overflow: hidden; + + mat-card.results-area, + .results-area { + background-color: #fff; + border-radius: 20px; + padding: 0; + box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.1), 0 1px 2px 0 rgba(60, 64, 67, 0.06); + border: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + box-sizing: border-box; + overflow: hidden; + flex: 1; + min-height: 0; + + .results-card-content { + padding: 0; + margin: 0; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; + } + } + + .rt-toolbar { + display: flex; + align-items: center; + gap: 4px; + min-height: 48px; + padding: 8px 16px; + flex-shrink: 0; + border-bottom: 1px solid #e1e3e1; + transition: background 0.2s; + + &.rt-toolbar-active { + background: #c2e7ff; + } + } + + .rt-count { + font-size: 0.875rem; + font-weight: 500; + color: #1f1f1f; + white-space: nowrap; + } + + .rt-divider { + width: 1px; + height: 20px; + margin: 0 8px; + background: #c4c7c5; + flex-shrink: 0; + } + + .rt-grow { + flex: 1; + } + + .rt-btn-text { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 32px; + padding: 0 10px; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + color: #0b57d0; + white-space: nowrap; + line-height: 1; + + &:hover:not(:disabled) { + background: rgba(11, 87, 208, 0.08); + } + + &:disabled { + color: #444746; + opacity: 0.38; + cursor: default; + } + + .mat-icon, + .material-symbols-outlined { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .rt-caret { + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; + margin-left: -2px; + } + } + + .rt-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: transparent; + cursor: pointer; + color: #444746; + + &:hover { + background: rgba(0, 0, 0, 0.06); + color: #1f1f1f; + } + + .material-symbols-outlined { + font-size: 20px; + } + } + + .active-density { + background-color: #e8f0fe; + color: #0b57d0; + font-weight: 500; + } + + .table-container { + flex: 1; + min-height: 0; + overflow: auto; + @include common.shared-scrollbar-style(); + transition: opacity 0.15s ease-in-out; + + &.stale-loading { + opacity: 0.6; + pointer-events: none; + } + + &.density-compact { + .results-table tr { + height: 40px; + } + } + + &.density-default { + .results-table tr { + height: 52px; + } + } + + &.density-comfortable { + .results-table tr { + height: 64px; + } + } + } + + .results-table { + table-layout: auto; + width: max-content; + min-width: 100%; + border-collapse: separate; + border-spacing: 0; + text-align: left; + font-size: 0.875rem; + + th, + .mat-mdc-header-cell, + .mat-header-cell { + position: sticky; + top: 0; + z-index: 2; + color: #444746; + font-weight: 600; + font-size: 0.75rem; + letter-spacing: 0.2px; + text-transform: uppercase; + padding: 8px 12px; + border-bottom: 1px solid #c4c7c5; + user-select: none; + background-color: #f8fafd; + white-space: nowrap; + text-align: left; + direction: ltr; + + &.sortable-header { + cursor: pointer; + text-align: left; + + &:hover { + color: #1f1f1f; + background-color: #f0f4f9; + } + } + + .header-content { + display: inline-flex; + align-items: center; + justify-content: flex-start; + text-align: left; + direction: ltr; + vertical-align: middle; + } + + .sort-icon { + font-size: 16px; + vertical-align: middle; + margin-left: 2px; + width: 16px; + height: 16px; + } + } + + td { + padding: 8px 12px; + border-bottom: 1px solid #e1e3e1; + color: #1f1f1f; + transition: background-color 0.15s ease; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + direction: ltr; + } + + tr:hover { + background-color: #f1f5fb; + } + + tr.selected-row { + background-color: #0b57d008; + } + + .empty-table-cell { + text-align: center; + padding: 32px; + color: #5f6368; + font-style: italic; + } + } + + .table-progress-bar { + @include mat.progress-bar-overrides(( + active-indicator-color: #0b57d0, + track-color: #d3e3fd, + )); + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + height: 3px; + margin: 0; + padding: 0; + direction: ltr; + } +} diff --git a/src/devtools/mobileharness/fe/v6/angular/app/shared/utils/date_utils.ts b/src/devtools/mobileharness/fe/v6/angular/app/shared/utils/date_utils.ts index e3a55aae45..617d7ce29c 100644 --- a/src/devtools/mobileharness/fe/v6/angular/app/shared/utils/date_utils.ts +++ b/src/devtools/mobileharness/fe/v6/angular/app/shared/utils/date_utils.ts @@ -226,6 +226,23 @@ export const dateUtils = { return `${diffDays}d ago`; }, + /** + * Formats duration milliseconds into a readable scale representation (h/m/s). + */ + formatDuration: (val: string | number | undefined | null): string | null => { + if (!val) return null; + const num = Number(val); + if (isNaN(num)) return String(val) || null; + const sec = Math.round(num / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + const remSec = sec % 60; + if (min < 60) return remSec > 0 ? `${min}m ${remSec}s` : `${min}m`; + const hrs = Math.floor(min / 60); + const remMin = min % 60; + return remMin > 0 ? `${hrs}h ${remMin}m` : `${hrs}h`; + }, + /** * Converts a date string or object to a Google Date object (year, month, day). *