From 528024eff23a954f8442260a0af18c3c8eae47f9 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 3 Apr 2026 20:08:12 +0300 Subject: [PATCH] Add hero search popup with mat-autocomplete, grouped results, and loading state - Search popup using mat-autocomplete with results grouped by item type - Debounced search with distinctUntilChanged, page size 10, most installed sort - Loading spinner shown immediately on first focus while fetching - No results state with search_off icon and contextual message - Clear button in search input to reset text - "See all results" footer navigates to /iot-hub/search page - Search button performs same navigation as "See all results" - Popup styled to match design: 424px max-height, 8px radius, layered shadow --- .../pages/iot-hub/iot-hub-home.component.html | 68 +++++- .../pages/iot-hub/iot-hub-home.component.scss | 213 +++++++++++++++++- .../pages/iot-hub/iot-hub-home.component.ts | 128 ++++++++++- .../assets/locale/locale.constant-en_US.json | 3 + 4 files changed, 402 insertions(+), 10 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html index b7a1eb9108..c50d573024 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html @@ -57,15 +57,77 @@ (mouseleave)="onHeroTypeLeave()">{{ ht.labelKey | translate }}@if (!last) {,} } - - + - + } + + + @if (searchLoading) { + + + + } @else if (searchResults.length === 0 && searchLoaded) { + +
+ search_off + @if (searchText?.trim()) { + {{ 'iot-hub.no-search-results' | translate:{ search: searchText.trim() } }} + } @else { + {{ 'iot-hub.no-items-available' | translate }} + } +
+
+ } @else { + @for (group of searchResultGroups; track group.type) { + + @for (item of group.items; track item.id) { + + @if (isCompactType(item.type)) { +
+ {{ getCompactIcon(item) }} +
+ } @else { +
+ @if (getItemImage(item); as imgUrl) { + + } @else { + {{ getItemTypeIcon(item.type) }} + } +
+ } +
+ {{ item.name }} + + person + {{ item.creatorDisplayName }} + +
+
+ } +
+ } + } + +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss index 11795fa233..c86ec1202c 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss @@ -210,7 +210,9 @@ box-shadow: 0 7px 16px 0 rgba(0, 0, 0, 0.04), 0 29px 29px 0 rgba(0, 0, 0, 0.03), - 0 64px 38px 0 rgba(0, 0, 0, 0.02); + 0 64px 38px 0 rgba(0, 0, 0, 0.02), + 0 114px 46px 0 rgba(0, 0, 0, 0.01), + 0 178px 50px 0 rgba(0, 0, 0, 0); } .mat-mdc-form-field-subscript-wrapper { @@ -218,6 +220,215 @@ } } +// Search popup panel (mat-autocomplete overlay) +::ng-deep .tb-search-popup-panel.mat-mdc-autocomplete-panel { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + padding: 16px 0 0; + max-height: 424px; + box-shadow: + 0 7px 16px 0 rgba(0, 0, 0, 0.04), + 0 29px 29px 0 rgba(0, 0, 0, 0.03), + 0 64px 38px 0 rgba(0, 0, 0, 0.02), + 0 114px 46px 0 rgba(0, 0, 0, 0.01), + 0 178px 50px 0 rgba(0, 0, 0, 0); + + // Section group — 12px gap between groups + .mat-mdc-optgroup + .mat-mdc-optgroup .mat-mdc-optgroup-label { + margin-top: 12px; + } + + // Section title — Design: 14px Medium, 0.25 tracking, black-tertiary, 4px V / 16px H + .mat-mdc-optgroup .mat-mdc-optgroup-label { + padding: 4px 16px; + min-height: 28px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + white-space: nowrap; + + .mdc-list-item__primary-text { + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + } + } + + // Item row — Design: 8px V / 16px H, 16px gap, centered + .mat-mdc-optgroup .mat-mdc-option:not(.mat-mdc-option-multiple) { + padding: 8px 16px; + min-height: auto; + height: auto; + + &:hover:not(.mdc-list-item--disabled), + &.mat-mdc-option-active:not(.mdc-list-item--disabled) { + background: rgba(0, 0, 0, 0.04); + } + + .mat-icon, tb-icon { + margin-right: 0; + } + + .mdc-list-item__primary-text { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + } + } + + // Thumbnail — Design: 99px x 56px, 4px radius, 1px border, 4% bg + .tb-search-popup-thumbnail { + width: 99px; + height: 56px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.04); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + + .tb-search-popup-thumb-placeholder { + font-size: 24px; + width: 24px; + height: 24px; + color: rgba(0, 0, 0, 0.38); + } + + // Icon square — Design: 56x56, 4.667px radius, colored bg + .tb-search-popup-icon-square { + width: 56px; + height: 56px; + border-radius: 4.667px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .tb-search-popup-icon-inner { + font-size: 28px; + width: 28px; + height: 28px; + color: white !important; + --tb-icon-color: white; + } + + // Text block — Design: flex-col, 4px gap, flex 1 + .tb-search-popup-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + } + + // Name — Design: 14px Medium, 0.25 tracking, black-primary, ellipsis + .tb-search-popup-name { + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + // Author — Design: 12px Regular, 0.25 tracking, black-tertiary, 16px person icon + .tb-search-popup-author { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + white-space: nowrap; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-right: 0; + color: rgba(0, 0, 0, 0.54); + } + } + + // Loading + .tb-search-popup-loading { + pointer-events: none; + opacity: 1 !important; + display: flex; + justify-content: center; + padding: 24px 16px; + min-height: auto; + } + + // No results + .tb-search-popup-no-results { + pointer-events: none; + opacity: 1 !important; + } + + .tb-search-popup-no-results-content { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 0; + color: rgba(0, 0, 0, 0.87); + font-size: 14px; + font-weight: 500; + line-height: 20px; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: rgba(0, 0, 0, 0.76); + } + } + + // Footer — Design: 40px h, 8px V / 16px H, 1px top border, tb-primary text + .tb-search-popup-footer { + position: sticky; + bottom: 0; + border-top: 1px solid rgba(0, 0, 0, 0.12); + height: 40px; + min-height: 40px; + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: flex-start; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: $tb-primary-color; + cursor: pointer; + background: white; + white-space: nowrap; + + &:hover { + background: #f5f5f5; + } + } +} + // Category cards — Design: centered 1200px container, flex-col gap=20, pb=48, 3-col rows .tb-iot-hub-categories { display: grid; diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts index d00897c7df..6cb6777a49 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts @@ -14,16 +14,20 @@ /// limitations under the License. /// -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; import { Router } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; -import { forkJoin } from 'rxjs'; +import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { forkJoin, Subject, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; import { PageLink } from '@shared/models/page/page-link'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from './iot-hub-item-detail-dialog.component'; import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component'; @@ -46,6 +50,11 @@ interface HeroTypeConfig { icons: string[]; } +interface SearchResultGroup { + type: ItemType; + items: MpItemVersionView[]; +} + @Component({ selector: 'tb-iot-hub-home', standalone: false, @@ -57,6 +66,15 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { readonly ItemType = ItemType; searchText = ''; + searchResults: MpItemVersionView[] = []; + searchResultGroups: SearchResultGroup[] = []; + searchLoaded = false; + searchLoading = false; + @ViewChild(MatAutocompleteTrigger) searchAutoTrigger: MatAutocompleteTrigger; + @ViewChild('searchInput', {read: ElementRef}) searchInputRef: ElementRef; + private searchSubject = new Subject(); + private searchSubscription: Subscription; + heroTypes: HeroTypeConfig[] = [ { @@ -119,11 +137,28 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { constructor( private router: Router, private dialog: MatDialog, - private iotHubApiService: IotHubApiService + private iotHubApiService: IotHubApiService, + private translate: TranslateService ) {} ngOnInit(): void { this.loadPopularItems(); + this.searchSubscription = this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap(text => { + this.searchLoading = true; + const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC }; + const pageLink = new PageLink(10, 0, text.trim() || null, sortOrder); + const query = new MpItemVersionQuery(pageLink); + return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true }); + }) + ).subscribe(result => { + this.searchResults = result.data; + this.searchResultGroups = this.groupSearchResults(result.data); + this.searchLoaded = true; + this.searchLoading = false; + }); // One-tick delay so Angular renders icons in hidden state first, then triggers transition requestAnimationFrame(() => { this.heroIconsReady = true; @@ -133,6 +168,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.stopHeroCycle(); + this.searchSubscription?.unsubscribe(); } onHeroTypeHover(config: HeroTypeConfig): void { @@ -159,11 +195,78 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { } } + onSearchInput(): void { + this.searchLoading = true; + this.searchSubject.next(this.searchText || ''); + } + + onSearchFocus(): void { + if (!this.searchLoaded) { + this.searchLoading = true; + this.searchSubject.next(this.searchText || ''); + } + } + + searchDisplayFn = (value: any): string => { + return typeof value === 'string' ? value : this.searchText || ''; + }; + + onSearchOptionSelected(event: MatAutocompleteSelectedEvent): void { + const item = event.option.value as MpItemVersionView; + this.searchText = ''; + this.searchAutoTrigger?.closePanel(); + this.openItemDetail(item); + } + + clearSearch(): void { + this.searchText = ''; + this.searchSubject.next(''); + this.searchInputRef?.nativeElement?.focus(); + setTimeout(() => this.searchAutoTrigger?.openPanel()); + } + onSearch(): void { - if (this.searchText?.trim()) { - this.router.navigate(['/iot-hub', this.getTypeRoute(this.activeHeroType?.type || ItemType.WIDGET)], - { queryParams: { search: this.searchText.trim() } }); + this.seeAllResults(); + } + + seeAllResults(): void { + this.searchAutoTrigger?.closePanel(); + const search = this.searchText?.trim() || undefined; + this.router.navigate(['/iot-hub/search'], { queryParams: { search } }); + } + + isCompactType(type: ItemType): boolean { + return type === ItemType.CALCULATED_FIELD || type === ItemType.RULE_CHAIN; + } + + getCompactIcon(item: MpItemVersionView): string { + return item.icon || (item.type === ItemType.CALCULATED_FIELD ? 'functions' : 'settings_ethernet'); + } + + getItemImage(item: MpItemVersionView): string | null { + if (item.image) { + return this.iotHubApiService.resolveResourceUrl(item.image); } + const resource = item.resources?.find(r => r.type === 'SCREENSHOT') || item.resources?.find(r => r.type === 'ICON'); + if (resource) { + return this.iotHubApiService.resolveResourceUrl(`/api/resources/${resource.id}`); + } + return null; + } + + getItemTypeIcon(type: ItemType): string { + switch (type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboard'; + case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; + case ItemType.DEVICE: return 'memory'; + default: return 'category'; + } + } + + getSearchGroupLabel(type: ItemType): string { + const key = itemTypeTranslations.get(type); + return key ? this.translate.instant(key + '-plural') : type; } navigateToBrowse(type: ItemType): void { @@ -371,4 +474,17 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { } }); } + + private groupSearchResults(items: MpItemVersionView[]): SearchResultGroup[] { + const groupMap = new Map(); + for (const item of items) { + let list = groupMap.get(item.type); + if (!list) { + list = []; + groupMap.set(item.type, list); + } + list.push(item); + } + return Array.from(groupMap.entries()).map(([type, groupItems]) => ({ type, items: groupItems })); + } } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index c1d12b271c..793f3bc8ac 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3704,6 +3704,9 @@ "and-devices": "& Devices", "search-placeholder": "Search in IoT Hub...", "search": "Search...", + "see-all-results": "See all results", + "no-search-results": "No results found for '{{ search }}'", + "no-items-available": "No items available", "items-per-page": "Items per page", "filter": "Filter", "available": "Available",