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) {,}
}
-
-
+
-
+
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",