Browse Source

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
pull/15508/head
Igor Kulikov 4 months ago
parent
commit
528024eff2
  1. 68
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html
  2. 213
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss
  3. 128
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  4. 3
      ui-ngx/src/assets/locale/locale.constant-en_US.json

68
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) {,}</span>
}
</div>
<mat-form-field appearance="outline" class="tb-iot-hub-hero-search">
<input matInput
<mat-form-field appearance="outline" class="tb-iot-hub-hero-search" #searchField>
<input matInput #searchInput
[placeholder]="'iot-hub.search-placeholder' | translate"
[(ngModel)]="searchText"
[matAutocomplete]="searchAuto"
(input)="onSearchInput()"
(focus)="onSearchFocus()"
(keyup.enter)="onSearch()">
<button matSuffix mat-icon-button (click)="onSearch()">
@if (searchText) {
<button matSuffix mat-icon-button (click)="clearSearch()">
<mat-icon>close</mat-icon>
</button>
}
<button matSuffix mat-icon-button (mousedown)="$event.stopPropagation()" (click)="onSearch()">
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
<mat-autocomplete #searchAuto="matAutocomplete"
class="tb-search-popup-panel"
[displayWith]="searchDisplayFn"
(optionSelected)="onSearchOptionSelected($event)"
[hideSingleSelectionIndicator]="true">
@if (searchLoading) {
<mat-option disabled class="tb-search-popup-loading">
<mat-spinner diameter="24"></mat-spinner>
</mat-option>
} @else if (searchResults.length === 0 && searchLoaded) {
<mat-option disabled class="tb-search-popup-no-results">
<div class="tb-search-popup-no-results-content">
<mat-icon>search_off</mat-icon>
@if (searchText?.trim()) {
<span>{{ 'iot-hub.no-search-results' | translate:{ search: searchText.trim() } }}</span>
} @else {
<span>{{ 'iot-hub.no-items-available' | translate }}</span>
}
</div>
</mat-option>
} @else {
@for (group of searchResultGroups; track group.type) {
<mat-optgroup [label]="getSearchGroupLabel(group.type)">
@for (item of group.items; track item.id) {
<mat-option [value]="item">
@if (isCompactType(item.type)) {
<div class="tb-search-popup-icon-square" [style.background]="item.color || '#048ad3'">
<tb-icon class="tb-search-popup-icon-inner">{{ getCompactIcon(item) }}</tb-icon>
</div>
} @else {
<div class="tb-search-popup-thumbnail">
@if (getItemImage(item); as imgUrl) {
<img [src]="imgUrl" alt="">
} @else {
<mat-icon class="tb-search-popup-thumb-placeholder">{{ getItemTypeIcon(item.type) }}</mat-icon>
}
</div>
}
<div class="tb-search-popup-text">
<span class="tb-search-popup-name">{{ item.name }}</span>
<span class="tb-search-popup-author">
<mat-icon>person</mat-icon>
{{ item.creatorDisplayName }}
</span>
</div>
</mat-option>
}
</mat-optgroup>
}
}
<div class="tb-search-popup-footer" (click)="seeAllResults()">
{{ 'iot-hub.see-all-results' | translate }}
</div>
</mat-autocomplete>
</div>
</div>

213
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;

128
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<string>();
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<ItemType, MpItemVersionView[]>();
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 }));
}
}

3
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",

Loading…
Cancel
Save