Browse Source
- New TbIotHubSearchComponent at /iot-hub/search with ?search= query param - Results grouped by item type with section headers linking to type pages - 5-column grid for widgets/dashboards, 3-column for calculated fields/rule chains - Search input with clear button, sort dropdown, pagination with page numbers - Empty state with search_off icon when no results found - Reuses item card component with install/update/delete dialog handlingpull/15508/head
6 changed files with 795 additions and 1 deletions
@ -0,0 +1,148 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
--> |
||||
|
<div class="tb-search-page"> |
||||
|
<!-- Back --> |
||||
|
<button mat-button color="primary" class="tb-search-back-btn" (click)="navigateBack()"> |
||||
|
<mat-icon>chevron_left</mat-icon> |
||||
|
{{ 'iot-hub.back-to-iot-hub' | translate }} |
||||
|
</button> |
||||
|
|
||||
|
<!-- Title --> |
||||
|
<h1 class="tb-search-title"> |
||||
|
@if (searchText?.trim()) { |
||||
|
{{ 'iot-hub.search-results-for' | translate }}<span class="tb-search-title-query">'{{ searchText.trim() }}'</span> |
||||
|
} @else { |
||||
|
{{ 'iot-hub.search-results' | translate }} |
||||
|
} |
||||
|
</h1> |
||||
|
|
||||
|
<!-- Search + Sort row --> |
||||
|
<div class="tb-search-toolbar"> |
||||
|
<mat-form-field appearance="outline" class="tb-search-input-field"> |
||||
|
<input matInput |
||||
|
[(ngModel)]="searchText" |
||||
|
(input)="onSearchInput()" |
||||
|
(keyup.enter)="onSearchEnter()"> |
||||
|
@if (searchText) { |
||||
|
<button matSuffix mat-icon-button (click)="clearSearch()"> |
||||
|
<mat-icon>close</mat-icon> |
||||
|
</button> |
||||
|
} |
||||
|
<button matSuffix mat-icon-button (click)="$event.stopPropagation(); onSearchEnter()"> |
||||
|
<mat-icon>search</mat-icon> |
||||
|
</button> |
||||
|
</mat-form-field> |
||||
|
<button mat-button class="tb-search-sort-btn" [matMenuTriggerFor]="sortMenu"> |
||||
|
<mat-icon>filter_list</mat-icon> |
||||
|
{{ sortOptions[selectedSortIndex].label | translate }} |
||||
|
</button> |
||||
|
<mat-menu #sortMenu="matMenu"> |
||||
|
@for (option of sortOptions; track option.value; let i = $index) { |
||||
|
<button mat-menu-item (click)="onSortChange(i)">{{ option.label | translate }}</button> |
||||
|
} |
||||
|
</mat-menu> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Results count + Pagination (shown when results exist) --> |
||||
|
@if (!isLoading && totalElements > 0) { |
||||
|
<div class="tb-search-pagination-row"> |
||||
|
<span class="tb-search-results-count"> |
||||
|
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }} |
||||
|
</span> |
||||
|
<div class="tb-search-pagination"> |
||||
|
<div class="tb-search-pagination-pages"> |
||||
|
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)"> |
||||
|
<mat-icon>chevron_left</mat-icon> |
||||
|
</button> |
||||
|
@for (p of getPageNumbers(); track p) { |
||||
|
<button class="tb-search-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)"> |
||||
|
{{ p + 1 }} |
||||
|
</button> |
||||
|
} |
||||
|
<button mat-icon-button [disabled]="pageIndex >= totalPages - 1" (click)="goToPage(pageIndex + 1)"> |
||||
|
<mat-icon>chevron_right</mat-icon> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="tb-search-page-size"> |
||||
|
<span>{{ 'iot-hub.items-per-page' | translate }}</span> |
||||
|
<select [ngModel]="pageSize" (ngModelChange)="onPageSizeChange($event)"> |
||||
|
@for (size of pageSizeOptions; track size) { |
||||
|
<option [value]="size">{{ size }}</option> |
||||
|
} |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- No results count when 0 --> |
||||
|
@if (!isLoading && totalElements === 0 && searchText?.trim()) { |
||||
|
<div class="tb-search-pagination-row"> |
||||
|
<span class="tb-search-results-count"> |
||||
|
{{ 'iot-hub.results-count' | translate:{ count: 0 } }} |
||||
|
</span> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Loading --> |
||||
|
@if (isLoading) { |
||||
|
<div class="tb-search-loading"> |
||||
|
<mat-spinner diameter="40"></mat-spinner> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- No results --> |
||||
|
@if (!isLoading && totalElements === 0 && searchText?.trim()) { |
||||
|
<div class="tb-search-empty"> |
||||
|
<mat-icon class="tb-search-empty-icon">search_off</mat-icon> |
||||
|
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3> |
||||
|
<p>{{ 'iot-hub.try-adjusting-search' | translate }}</p> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Results grouped by type --> |
||||
|
@if (!isLoading && resultGroups.length > 0) { |
||||
|
<div class="tb-search-categories"> |
||||
|
@for (group of resultGroups; track group.type) { |
||||
|
<div class="tb-search-section"> |
||||
|
<!-- Section header --> |
||||
|
<button mat-button class="tb-search-section-header" (click)="navigateToType(group.type)"> |
||||
|
{{ getTypeLabel(group.type) }} |
||||
|
<mat-icon>chevron_right</mat-icon> |
||||
|
</button> |
||||
|
<!-- Card grid --> |
||||
|
<div class="tb-search-card-grid" [class.tb-search-card-grid-compact]="isCompactType(group.type)"> |
||||
|
@for (item of group.items; track item.id) { |
||||
|
<tb-iot-hub-item-card |
||||
|
[item]="item" |
||||
|
[installedItem]="getInstalledItem(item)" |
||||
|
[showTypeChip]="false" |
||||
|
[showSubtype]="false" |
||||
|
(cardClick)="openItemDetail($event)" |
||||
|
(creatorClick)="navigateToCreator($event)" |
||||
|
(installClick)="installItem($event)" |
||||
|
(updateClick)="updateItem($event)" |
||||
|
(deleteClick)="deleteInstalledItem($event)"> |
||||
|
</tb-iot-hub-item-card> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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<string>(); |
||||
|
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<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 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(); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue