diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts index 2381d535db..d83266032b 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts @@ -22,6 +22,7 @@ import { TbIotHubHomeComponent } from './iot-hub-home.component'; import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component'; import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component'; import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; +import { TbIotHubSearchComponent } from './iot-hub-search.component'; const routes: Routes = [ { @@ -102,6 +103,18 @@ const routes: Routes = [ breadcrumb: { label: 'iot-hub.device-library', icon: 'memory' } } }, + { + path: 'search', + component: TbIotHubSearchComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.search-results', + breadcrumb: { + label: 'iot-hub.search-results', + icon: 'search' + } + } + }, { path: 'installed', component: TbIotHubInstalledItemsComponent, diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.html new file mode 100644 index 0000000000..577ad0873a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.html @@ -0,0 +1,148 @@ + +
+ + + + +

+ @if (searchText?.trim()) { + {{ 'iot-hub.search-results-for' | translate }}'{{ searchText.trim() }}' + } @else { + {{ 'iot-hub.search-results' | translate }} + } +

+ + +
+ + + @if (searchText) { + + } + + + + + @for (option of sortOptions; track option.value; let i = $index) { + + } + +
+ + + @if (!isLoading && totalElements > 0) { +
+ + {{ 'iot-hub.results-count' | translate:{ count: totalElements } }} + +
+
+ + @for (p of getPageNumbers(); track p) { + + } + +
+
+ {{ 'iot-hub.items-per-page' | translate }} + +
+
+
+ } + + + @if (!isLoading && totalElements === 0 && searchText?.trim()) { +
+ + {{ 'iot-hub.results-count' | translate:{ count: 0 } }} + +
+ } + + + @if (isLoading) { +
+ +
+ } + + + @if (!isLoading && totalElements === 0 && searchText?.trim()) { +
+ search_off +

{{ 'iot-hub.no-items-found' | translate }}

+

{{ 'iot-hub.try-adjusting-search' | translate }}

+
+ } + + + @if (!isLoading && resultGroups.length > 0) { +
+ @for (group of resultGroups; track group.type) { +
+ + + +
+ @for (item of group.items; track item.id) { + + + } +
+
+ } +
+ } +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.scss new file mode 100644 index 0000000000..4c990d652a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.scss @@ -0,0 +1,267 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "../../../../../scss/constants"; + +:host { + display: block; + padding: 16px; +} + +.tb-search-page { + background: white; + border-radius: 8px; + padding: 40px; + display: flex; + flex-direction: column; + gap: 40px; +} + +// Back button +.tb-search-back-btn { + align-self: flex-start; + margin-bottom: -20px; +} + +// Title — Design: 28px Medium, black-primary +.tb-search-title { + font-size: 28px; + font-weight: 500; + line-height: 36px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-search-title-query { + color: rgba(0, 0, 0, 0.54); +} + +// Search + Sort toolbar +.tb-search-toolbar { + display: flex; + align-items: center; + gap: 16px; +} + +.tb-search-input-field { + width: 480px; + + ::ng-deep .mat-mdc-text-field-wrapper { + border-radius: 8px; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } +} + +.tb-search-sort-btn { + color: $tb-primary-color; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + margin-left: auto; +} + +// Results count + Pagination row +.tb-search-pagination-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: -28px; +} + +.tb-search-results-count { + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: rgba(0, 0, 0, 0.76); +} + +.tb-search-pagination { + display: flex; + align-items: center; + gap: 24px; +} + +.tb-search-pagination-pages { + display: flex; + align-items: center; + gap: 1px; +} + +.tb-search-page-btn { + min-width: 40px; + height: 40px; + border: none; + background: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + cursor: pointer; + padding: 8px 16px; + font-family: inherit; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + &.active { + background: $tb-primary-color; + color: white; + } +} + +.tb-search-page-size { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: rgba(0, 0, 0, 0.76); + + select { + width: 77px; + height: 40px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 8px 12px; + font-size: 14px; + font-family: inherit; + background: white; + cursor: pointer; + } +} + +// Loading +.tb-search-loading { + display: flex; + justify-content: center; + padding: 48px 0; + margin-top: -20px; +} + +// Empty state — Design: centered, 100px icon, 20px title, 14px subtitle +.tb-search-empty { + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 0; + margin-top: -20px; +} + +.tb-search-empty-icon { + font-size: 100px; + width: 100px; + height: 100px; + color: rgba(0, 0, 0, 0.38); + margin-bottom: 20px; +} + +.tb-search-empty h3 { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.76); + margin: 0 0 8px; +} + +.tb-search-empty p { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.54); + margin: 0; +} + +// Categories / sections +.tb-search-categories { + display: flex; + flex-direction: column; + gap: 48px; + margin-top: -20px; +} + +// Section header — Design: 18px Medium, clickable, right chevron +.tb-search-section-header { + font-size: 18px; + font-weight: 500; + line-height: 24px; + color: rgba(0, 0, 0, 0.87); + padding: 0; + margin-bottom: 12px; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } +} + +// Card grid — Design: 5-column for big cards, 3-column for compact +.tb-search-card-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 20px; + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(3, 1fr); + } +} + +// Responsive +@media (max-width: 1400px) { + .tb-search-card-grid { + grid-template-columns: repeat(4, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(2, 1fr); + } + } +} + +@media (max-width: 1100px) { + .tb-search-card-grid { + grid-template-columns: repeat(3, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(2, 1fr); + } + } +} + +@media (max-width: 900px) { + .tb-search-card-grid { + grid-template-columns: repeat(2, 1fr); + + &.tb-search-card-grid-compact { + grid-template-columns: repeat(1, 1fr); + } + } + + .tb-search-input-field { + width: 100%; + flex: 1; + } + + .tb-search-page { + padding: 24px; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.ts new file mode 100644 index 0000000000..3151912edb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search.component.ts @@ -0,0 +1,361 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +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 { 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'; +import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component'; +import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component'; + +interface SearchResultGroup { + type: ItemType; + items: MpItemVersionView[]; +} + +interface SortOption { + value: string; + label: string; + direction: Direction; +} + +const TYPE_ORDER: ItemType[] = [ + ItemType.WIDGET, ItemType.DASHBOARD, ItemType.SOLUTION_TEMPLATE, + ItemType.CALCULATED_FIELD, ItemType.RULE_CHAIN, ItemType.DEVICE +]; + +@Component({ + selector: 'tb-iot-hub-search', + standalone: false, + templateUrl: './iot-hub-search.component.html', + styleUrls: ['./iot-hub-search.component.scss'] +}) +export class TbIotHubSearchComponent implements OnInit, OnDestroy { + + readonly ItemType = ItemType; + + searchText = ''; + resultGroups: SearchResultGroup[] = []; + totalElements = 0; + isLoading = false; + + pageSize = 15; + pageIndex = 0; + pageSizeOptions = [15, 30, 60]; + + sortOptions: SortOption[] = [ + { value: 'totalInstallCount', label: 'iot-hub.sort-most-installed', direction: Direction.DESC }, + { value: 'publishedTime', label: 'iot-hub.sort-newest', direction: Direction.DESC }, + { value: 'name', label: 'iot-hub.sort-name', direction: Direction.ASC } + ]; + selectedSortIndex = 0; + + installedWidgets: IotHubInstalledItem[] = []; + installedSolutionTemplates: IotHubInstalledItem[] = []; + + private searchSubject = new Subject(); + private searchSubscription: Subscription; + + constructor( + private route: ActivatedRoute, + private router: Router, + private dialog: MatDialog, + private translate: TranslateService, + private iotHubApiService: IotHubApiService + ) {} + + ngOnInit(): void { + this.loadInstalledItems(); + this.searchSubscription = this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap(text => { + this.isLoading = true; + this.pageIndex = 0; + return this.fetchResults(text); + }) + ).subscribe(result => { + this.applyResults(result.data, result.totalElements); + }); + + const search = this.route.snapshot.queryParamMap.get('search'); + if (search) { + this.searchText = search; + } + this.loadResults(); + } + + ngOnDestroy(): void { + this.searchSubscription?.unsubscribe(); + } + + navigateBack(): void { + this.router.navigate(['/iot-hub']); + } + + onSearchInput(): void { + this.searchSubject.next(this.searchText || ''); + } + + clearSearch(): void { + this.searchText = ''; + this.loadResults(); + } + + onSearchEnter(): void { + this.loadResults(); + } + + onSortChange(index: number): void { + this.selectedSortIndex = index; + this.pageIndex = 0; + this.loadResults(); + } + + // Pagination + get totalPages(): number { + return Math.ceil(this.totalElements / this.pageSize) || 0; + } + + getPageNumbers(): number[] { + const total = this.totalPages; + if (total <= 5) { + return Array.from({length: total}, (_, i) => i); + } + const pages: number[] = []; + let start = Math.max(0, this.pageIndex - 2); + let end = Math.min(total - 1, start + 4); + if (end - start < 4) { + start = Math.max(0, end - 4); + } + for (let i = start; i <= end; i++) { + pages.push(i); + } + return pages; + } + + goToPage(page: number): void { + if (page >= 0 && page < this.totalPages) { + this.pageIndex = page; + this.loadResults(); + } + } + + onPageSizeChange(size: number): void { + this.pageSize = size; + this.pageIndex = 0; + this.loadResults(); + } + + // Type helpers + isCompactType(type: ItemType): boolean { + return type === ItemType.CALCULATED_FIELD || type === ItemType.RULE_CHAIN; + } + + getTypeLabel(type: ItemType): string { + const key = itemTypeTranslations.get(type); + return key ? this.translate.instant(key + '-plural') : type; + } + + getTypeRoute(type: ItemType): string { + switch (type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboards'; + case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; + case ItemType.CALCULATED_FIELD: return 'calculated-fields'; + case ItemType.RULE_CHAIN: return 'rule-chains'; + case ItemType.DEVICE: return 'devices'; + default: return 'widgets'; + } + } + + navigateToType(type: ItemType): void { + this.router.navigate(['/iot-hub', this.getTypeRoute(type)]); + } + + // Installed items + getInstalledItem(item: MpItemVersionView): IotHubInstalledItem | undefined { + switch (item.type) { + case ItemType.WIDGET: + return this.installedWidgets.find(i => i.itemId === item.itemId); + case ItemType.SOLUTION_TEMPLATE: + return this.installedSolutionTemplates.find(i => i.itemId === item.itemId); + default: + return undefined; + } + } + + // Dialogs + openItemDetail(item: MpItemVersionView): void { + const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + autoFocus: false, + data: { + item, + iotHubApiService: this.iotHubApiService, + installedItem: this.getInstalledItem(item) + } as IotHubItemDetailDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'installed' || result === 'deleted' || result === 'updated') { + this.reloadInstalledItems(); + } + }); + } + + installItem(item: MpItemVersionView): void { + if (item.type === ItemType.DEVICE) { + this.installDevice(item); + return; + } + const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { + panelClass: ['tb-dialog'], + autoFocus: false, + data: { item, iotHubApiService: this.iotHubApiService } as IotHubInstallDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'installed') { + this.reloadInstalledItems(); + } + }); + } + + private installDevice(item: MpItemVersionView): void { + this.iotHubApiService.getVersionFileData(item.id as string, { ignoreLoading: true }).subscribe({ + next: async (blob: Blob) => { + const zipData = await blob.arrayBuffer(); + const dialogRef = this.dialog.open(TbDeviceInstallDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + disableClose: true, + autoFocus: false, + data: { item, zipData, iotHubApiService: this.iotHubApiService } as DeviceInstallDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'installed') { + this.reloadInstalledItems(); + } + }); + } + }); + } + + updateItem(item: MpItemVersionView): void { + const installedItem = this.getInstalledItem(item); + if (!installedItem) { return; } + const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { + panelClass: ['tb-dialog'], + autoFocus: false, + data: { + installedItemId: installedItem.id.id, + itemName: item.name, + itemType: item.type, + version: item.version, + versionId: item.id, + iotHubApiService: this.iotHubApiService + } as IotHubUpdateDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'updated') { + this.reloadInstalledItems(); + } + }); + } + + deleteInstalledItem(item: MpItemVersionView): void { + const installedItem = this.getInstalledItem(item); + if (!installedItem) { return; } + const dialogRef = this.dialog.open(TbIotHubDeleteDialogComponent, { + panelClass: ['tb-dialog'], + autoFocus: false, + data: { itemName: item.name, itemType: item.type } as IotHubDeleteDialogData + }); + dialogRef.afterClosed().subscribe(confirmed => { + if (confirmed) { + this.iotHubApiService.deleteInstalledItem(installedItem.id.id).subscribe(() => { + this.reloadInstalledItems(); + }); + } + }); + } + + navigateToCreator(creatorId: string): void { + this.router.navigate(['/iot-hub/creator', creatorId]); + } + + // Data loading + private loadResults(): void { + this.isLoading = true; + this.fetchResults(this.searchText || '').subscribe(result => { + this.applyResults(result.data, result.totalElements); + }); + } + + private fetchResults(text: string) { + const sort = this.sortOptions[this.selectedSortIndex]; + const sortOrder: SortOrder = { property: sort.value, direction: sort.direction }; + const pageLink = new PageLink(this.pageSize, this.pageIndex, text.trim() || null, sortOrder); + const query = new MpItemVersionQuery(pageLink); + return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true }); + } + + private applyResults(data: MpItemVersionView[], totalElements: number): void { + this.totalElements = totalElements; + this.resultGroups = this.groupResults(data); + this.isLoading = false; + } + + private groupResults(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 TYPE_ORDER + .filter(type => groupMap.has(type)) + .map(type => ({ type, items: groupMap.get(type) })); + } + + private loadInstalledItems(): void { + const config = { ignoreLoading: true }; + const pageLink = new PageLink(10000, 0); + forkJoin({ + widgets: this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, config), + solutionTemplates: this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, config) + }).subscribe(results => { + this.installedWidgets = results.widgets.data; + this.installedSolutionTemplates = results.solutionTemplates.data; + }); + } + + private reloadInstalledItems(): void { + this.loadInstalledItems(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts index 9c0f66a7bd..9016dbe4ba 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts @@ -29,6 +29,7 @@ import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.compo import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component'; import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component'; import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component'; +import { TbIotHubSearchComponent } from './iot-hub-search.component'; @NgModule({ declarations: [ @@ -42,7 +43,8 @@ import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-i TbIotHubInstalledItemsComponent, TbIotHubUpdateDialogComponent, TbIotHubDeleteDialogComponent, - TbDeviceInstallDialogComponent + TbDeviceInstallDialogComponent, + TbIotHubSearchComponent ], imports: [ CommonModule, 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 793f3bc8ac..550ef19222 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3707,6 +3707,9 @@ "see-all-results": "See all results", "no-search-results": "No results found for '{{ search }}'", "no-items-available": "No items available", + "search-results": "Search results", + "search-results-for": "Search results for ", + "try-adjusting-search": "Try adjusting your search", "items-per-page": "Items per page", "filter": "Filter", "available": "Available",