From 4bb857bf3ffce6ad35d8ef53b2a9694f05c4df2d Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 7 Apr 2026 10:34:56 +0300 Subject: [PATCH] Add IoT Hub widget filters, All/Installed toggle, and installed badge - Add All/Installed toggle in IoT Hub mode header - Installed mode fetches published versions for installed widgets with in-memory filtering - Filter panel with Type, Category, Use Case sections via tb-popover - Filter badge count on filter button - Installed badge (check + label) on widget cards in All mode - Widget type badge from dataDescriptor on IoT Hub widget cards - Filters apply to both All (server-side) and Installed (in-memory) modes - Load installed widgets on entering IoT Hub mode - Skip detail dialog for already-installed widgets, emit directly --- .../dashboard-page.component.html | 23 ++ .../dashboard-widget-select.component.html | 99 ++++++++- .../dashboard-widget-select.component.scss | 94 +++++++- .../dashboard-widget-select.component.ts | 209 ++++++++++++++++-- .../assets/locale/locale.constant-en_US.json | 2 + 5 files changed, 398 insertions(+), 29 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html index 78dab85ad9..2826c333c0 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html @@ -455,6 +455,29 @@ {{ 'widget.actual' | translate }} {{ 'widget.deprecated' | translate }} + + + {{ 'widget.all' | translate }} + {{ 'iot-hub.installed' | translate }} + - - + + + + + + + +
@@ -142,6 +156,7 @@ {{item.name}}
+
{{ 'widget.' + item.dataDescriptor.widgetType + '-short' | translate }}
i
@@ -155,6 +170,10 @@ person {{ item.creatorDisplayName }} + + check + {{ 'iot-hub.installed' | translate }} + file_download {{ item.totalInstallCount | shortNumber }} @@ -174,4 +193,64 @@ {{ 'iot-hub.no-items-found' | translate }}
+ + + +
+
+ {{ 'iot-hub.filters' | translate }} +
+
+ + + + {{ 'iot-hub.type' | translate }} + {{ iotHubActiveWidgetTypes.size }} + + +
+ + {{ entry.value | translate }} + +
+
+ + + + + {{ 'iot-hub.category' | translate }} + {{ iotHubActiveCategories.size }} + + +
+ + {{ entry.value | translate }} + +
+
+ + + + + {{ 'iot-hub.use-case' | translate }} + {{ iotHubActiveUseCases.size }} + + +
+ + {{ entry.value | translate }} + +
+
+
+
+ + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss index 1c40348ef4..cd1ce0e941 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss @@ -137,7 +137,6 @@ height: 100%; } } -} // IoT Hub widget card footer .tb-iot-hub-widget-card { @@ -167,6 +166,28 @@ } } + .tb-iot-hub-widget-installed-badge { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px 6px; + border-radius: 10px; + background: rgba(25, 128, 56, 0.06); + color: #198038; + font-size: 10px; + font-weight: 500; + line-height: 14px; + letter-spacing: 0.2px; + white-space: nowrap; + + mat-icon { + font-size: 12px; + width: 12px; + height: 12px; + color: #198038; + } + } + .tb-iot-hub-widget-installs { font-weight: 500; @@ -179,6 +200,77 @@ } } +// IoT Hub filter panel +.tb-iot-hub-filter-panel { + padding: 24px; + display: flex; + flex-direction: column; + gap: 12px; + max-height: 500px; + + .tb-iot-hub-filter-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .tb-iot-hub-filter-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + + .tb-iot-hub-filter-sections { + display: flex; + flex-direction: column; + overflow-y: auto; + + mat-expansion-panel { + box-shadow: none; + background: transparent; + + &::ng-deep .mat-expansion-panel-body { + padding: 0; + } + } + + mat-divider { + margin: 0; + } + } + + .tb-iot-hub-filter-option { + height: 40px; + display: flex; + align-items: center; + padding: 8px 0; + } + + .tb-iot-hub-filter-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + border-radius: 9px; + background: #FF5722; + color: white; + font-size: 11px; + font-weight: 600; + margin-left: 8px; + padding: 0 4px; + } + + .tb-iot-hub-filter-actions { + display: flex; + align-items: center; + gap: 8px; + padding-top: 8px; + } +} + @keyframes shine { to { background-position-x: -200%; diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts index 7123f43732..8adea5a22e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { IAliasController } from '@core/api/widget-api.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; @@ -26,8 +26,8 @@ import { widgetType, WidgetTypeInfo } from '@shared/models/widget.models'; -import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, skip, switchMap } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, forkJoin, of } from 'rxjs'; import { isObject } from '@core/utils'; import { PageLink } from '@shared/models/page/page-link'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; @@ -35,8 +35,9 @@ import { GridEntitiesFetchFunction, ScrollGridColumns } from '@shared/components import { ItemSizeStrategy } from '@shared/components/grid/scroll-grid.component'; import { coerceBoolean } from '@shared/decorators/coercion'; import { MatDialog } from '@angular/material/dialog'; -import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; -import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { MpItemVersionQuery, MpItemVersionView, widgetTypeTranslations as iotHubWidgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, getCategoriesForType, useCaseTranslations } 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, @@ -114,6 +115,16 @@ export class DashboardWidgetSelectComponent implements OnInit { this.filterWidgetTypes$.next(null); this.deprecatedFilter$.next(DeprecatedFilter.ACTUAL); this.selectWidgetMode$.next(mode); + if (mode === 'iotHub') { + this.loadInstalledWidgets(); + } else { + this.iotHubInstalledMode = 'all'; + this.installedWidgetVersions = null; + this.iotHubActiveWidgetTypes.clear(); + this.iotHubActiveCategories.clear(); + this.iotHubActiveUseCases.clear(); + this.iotHubFilterCount = 0; + } } } @@ -149,6 +160,8 @@ export class DashboardWidgetSelectComponent implements OnInit { return this.widgetsBundle$.value; } + @ViewChild('iotHubFilterPanel', {static: true}) iotHubFilterPanel: TemplateRef; + @Output() widgetSelected: EventEmitter = new EventEmitter(); @@ -177,6 +190,20 @@ export class DashboardWidgetSelectComponent implements OnInit { allWidgetsFilter: WidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL}; widgetsFilter: BundleWidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL, widgetsBundleId: null}; iotHubWidgetsFilter = ''; + iotHubInstalledMode: 'all' | 'installed' = 'all'; + iotHubInstalledWidgetsFetchFunction: GridEntitiesFetchFunction; + private installedWidgets: IotHubInstalledItem[] = null; + private installedWidgetVersions: MpItemVersionView[] = null; + iotHubInstalledWidgetsFilter = ''; + + // IoT Hub filter model + iotHubActiveWidgetTypes = new Set(); + iotHubActiveCategories = new Set(); + iotHubActiveUseCases = new Set(); + iotHubWidgetTypesMap: Map = iotHubWidgetTypeTranslations; + iotHubCategoriesMap: Map = getCategoriesForType(ItemType.WIDGET); + iotHubUseCasesMap: Map = useCaseTranslations as Map; + iotHubFilterCount = 0; constructor(private widgetsService: WidgetService, private iotHubApiService: IotHubApiService, @@ -210,19 +237,41 @@ export class DashboardWidgetSelectComponent implements OnInit { }; this.iotHubWidgetsFetchFunction = (pageSize, page, filter) => { + const search = typeof filter === 'string' ? filter.split('|')[0] : filter; const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC }; - const pageLink = new PageLink(pageSize, page, filter || null, sortOrder); - const query = new MpItemVersionQuery(pageLink, ItemType.WIDGET); + const pageLink = new PageLink(pageSize, page, search || null, sortOrder); + const query = new MpItemVersionQuery(pageLink, ItemType.WIDGET, + undefined, undefined, + this.iotHubActiveCategories.size > 0 ? Array.from(this.iotHubActiveCategories) : undefined, + this.iotHubActiveUseCases.size > 0 ? Array.from(this.iotHubActiveUseCases) : undefined, + undefined, + this.iotHubActiveWidgetTypes.size > 0 ? Array.from(this.iotHubActiveWidgetTypes) : undefined + ); return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true }); }; + this.iotHubInstalledWidgetsFetchFunction = (pageSize, page, filter) => { + if (this.installedWidgetVersions === null) { + return this.fetchInstalledWidgetVersions().pipe( + map(versions => this.filterAndPaginateInstalledVersions(versions, pageSize, page, filter)) + ); + } + return of(this.filterAndPaginateInstalledVersions(this.installedWidgetVersions, pageSize, page, filter)); + }; + this.search$.pipe( distinctUntilChanged(), skip(1) ).subscribe( (search) => { this.widgetsBundleFilter = search; - this.iotHubWidgetsFilter = search; + if (this.selectWidgetMode$.value === 'iotHub') { + if (this.iotHubInstalledMode === 'installed') { + this.iotHubInstalledWidgetsFilter = search; + } else { + this.iotHubWidgetsFilter = search; + } + } this.cd.markForCheck(); } ); @@ -274,6 +323,25 @@ export class DashboardWidgetSelectComponent implements OnInit { onIotHubWidgetClicked($event: Event, item: MpItemVersionView): void { $event.preventDefault(); + const installedItem = this.installedWidgets?.find(i => i.itemId === item.itemId); + if (installedItem) { + const widgetTypeId = installedItem.descriptor?.type === 'WIDGET' ? installedItem.descriptor.widgetTypeId?.id : null; + if (widgetTypeId) { + this.widgetsService.getWidgetTypeInfoById(widgetTypeId).subscribe(wt => { + if (wt) { + this.widgetSelected.emit({ + typeFullFqn: fullWidgetTypeFqn(wt), + type: wt.widgetType, + title: wt.name, + image: wt.image, + description: wt.description, + deprecated: wt.deprecated + }); + } + }); + } + return; + } const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], autoFocus: false, @@ -290,6 +358,10 @@ export class DashboardWidgetSelectComponent implements OnInit { }); } + isIotHubWidgetInstalled(item: MpItemVersionView): boolean { + return this.installedWidgets?.some(i => i.itemId === item.itemId) ?? false; + } + getIotHubItemImage(item: MpItemVersionView): string | null { if (item.image) { return this.iotHubApiService.resolveResourceUrl(item.image); @@ -301,6 +373,60 @@ export class DashboardWidgetSelectComponent implements OnInit { return null; } + onIotHubInstalledModeChange(mode: 'all' | 'installed'): void { + this.iotHubInstalledMode = mode; + if (mode === 'installed') { + this.installedWidgetVersions = null; + } + this.cd.markForCheck(); + } + + toggleIotHubWidgetType(key: string): void { + if (this.iotHubActiveWidgetTypes.has(key)) { + this.iotHubActiveWidgetTypes.delete(key); + } else { + this.iotHubActiveWidgetTypes.add(key); + } + } + + toggleIotHubCategory(key: string): void { + if (this.iotHubActiveCategories.has(key)) { + this.iotHubActiveCategories.delete(key); + } else { + this.iotHubActiveCategories.add(key); + } + } + + toggleIotHubUseCase(key: string): void { + if (this.iotHubActiveUseCases.has(key)) { + this.iotHubActiveUseCases.delete(key); + } else { + this.iotHubActiveUseCases.add(key); + } + } + + clearIotHubFilters(): void { + this.iotHubActiveWidgetTypes.clear(); + this.iotHubActiveCategories.clear(); + this.iotHubActiveUseCases.clear(); + this.applyIotHubFilters(); + } + + applyIotHubFilters(): void { + this.iotHubFilterCount = this.iotHubActiveWidgetTypes.size + this.iotHubActiveCategories.size + this.iotHubActiveUseCases.size; + this.reloadIotHubWidgets(); + } + + private reloadIotHubWidgets(): void { + if (this.iotHubInstalledMode === 'installed') { + this.installedWidgetVersions = null; + this.iotHubInstalledWidgetsFilter = this.searchSubject.value + '|' + Date.now(); + } else { + this.iotHubWidgetsFilter = this.searchSubject.value + '|' + Date.now(); + } + this.cd.markForCheck(); + } + isObject(value: any): boolean { return isObject(value); } @@ -310,18 +436,12 @@ export class DashboardWidgetSelectComponent implements OnInit { this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }).subscribe({ next: (result) => { if (result.success && result.descriptor?.type === 'WIDGET') { + this.loadInstalledWidgets(); const widgetTypeId = result.descriptor.widgetTypeId?.id; if (widgetTypeId) { - this.widgetsService.getWidgetTypeInfoById(widgetTypeId).subscribe(widgetType => { - if (widgetType) { - this.widgetSelected.emit({ - typeFullFqn: fullWidgetTypeFqn(widgetType), - type: widgetType.widgetType, - title: widgetType.name, - image: widgetType.image, - description: widgetType.description, - deprecated: widgetType.deprecated - }); + this.widgetsService.getWidgetTypeInfoById(widgetTypeId).subscribe(wt => { + if (wt) { + this.widgetSelected.emit(this.toWidgetInfo(wt)); } }); } @@ -330,6 +450,59 @@ export class DashboardWidgetSelectComponent implements OnInit { }); } + private loadInstalledWidgets(): void { + if (this.installedWidgets === null) { + this.installedWidgets = []; + } + const pageLink = new PageLink(10000, 0); + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, { ignoreLoading: true }).subscribe(data => { + this.installedWidgets = data.data; + }); + } + + private fetchInstalledWidgetVersions() { + const itemIds = (this.installedWidgets || []).map(i => i.itemId); + if (itemIds.length === 0) { + this.installedWidgetVersions = []; + return of([]); + } + return this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true }).pipe( + switchMap(infos => { + if (infos.length === 0) { + return of([]); + } + const versionRequests = infos.map(info => + this.iotHubApiService.getVersionInfo(info.publishedVersionId, { ignoreLoading: true }) + ); + return forkJoin(versionRequests); + }), + map(versions => { + this.installedWidgetVersions = versions.sort((a, b) => b.totalInstallCount - a.totalInstallCount); + return this.installedWidgetVersions; + }) + ); + } + + private filterAndPaginateInstalledVersions(versions: MpItemVersionView[], pageSize: number, page: number, filter: string) { + let filtered = versions; + const search = typeof filter === 'string' ? filter.split('|')[0] : ''; + if (search) { + filtered = filtered.filter(v => v.name.toLowerCase().includes(search.toLowerCase())); + } + if (this.iotHubActiveWidgetTypes.size > 0) { + filtered = filtered.filter(v => this.iotHubActiveWidgetTypes.has(v.dataDescriptor?.widgetType)); + } + if (this.iotHubActiveCategories.size > 0) { + filtered = filtered.filter(v => v.categories?.some(c => this.iotHubActiveCategories.has(c))); + } + if (this.iotHubActiveUseCases.size > 0) { + filtered = filtered.filter(v => v.useCases?.some(u => this.iotHubActiveUseCases.has(u))); + } + const start = page * pageSize; + const data = filtered.slice(start, start + pageSize); + return { data, totalPages: Math.ceil(filtered.length / pageSize), totalElements: filtered.length, hasNext: start + pageSize < filtered.length }; + } + private toWidgetInfo(widgetTypeInfo: WidgetTypeInfo): WidgetInfo { return { typeFullFqn: fullWidgetTypeFqn(widgetTypeInfo), 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 b41fe5adf2..95503b1b00 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3711,6 +3711,8 @@ "search-results-for": "Search results for ", "try-adjusting-search": "Try adjusting your search", "add-widget-from-iot-hub": "Add widget from IoT Hub", + "filters": "Filters", + "type": "Type", "items-per-page": "Items per page", "filter": "Filter", "available": "Available",