From 666e8f4b68d031ac833c0236ea073b9939959dd6 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 21 Apr 2026 17:06:20 +0300 Subject: [PATCH] feat(iot-hub): redesign dashboard widget select with self-contained header, unified filter panel, sort menu - Top-level toggle now Installed / IoT Hub; Installed default shows bundles + 'All widgets' logical card; IoT Hub default shows 'All widgets' + 'Installed from IoT Hub' logical cards + widget categories - Logical cards rendered inline in the scroll-grid via a fetch-function wrapper that prepends __logical discriminators - 120px 2-row mat-toolbar header built into the component (no more tb-details-panel wrapper) - Inline search input replaces tb-widgets-bundle-search (component deleted); search preserved across state transitions - Filter popover: unified style for both modes with mat-expansion-panel, Clear all + close in header, immediate-apply (no Apply/Cancel); badge count on filter button - Install-mode filter: widget types (empty = all) + Include Deprecated (visible for bundle or all-widgets) - Sort mat-menu (Most installed / Newest / Name), visible in iotHub sub-modes - scadaFirst input propagated to MpItemVersionQuery, getWidgetCategories, and installed-widget sort - Remove obsolete widget-types-panel component and editWidgetsTypesToDisplay flow - Add WidgetCategory model and getWidgetCategories API, add scadaFirst param --- .../src/app/core/http/iot-hub-api.service.ts | 18 +- ui-ngx/src/app/modules/common/modules-map.ts | 4 - .../dashboard-page.component.html | 98 +-- .../dashboard-page.component.scss | 1 + .../dashboard-page.component.ts | 64 -- .../dashboard-widget-select.component.html | 672 ++++++++++++------ .../dashboard-widget-select.component.scss | 165 ++++- .../dashboard-widget-select.component.ts | 490 ++++++++++--- .../widget-types-panel.component.html | 23 - .../widget-types-panel.component.scss | 36 - .../widget-types-panel.component.ts | 49 -- .../home/components/home-components.module.ts | 3 - .../widgets-bundle-search.component.html | 29 - .../widgets-bundle-search.component.scss | 55 -- .../widgets-bundle-search.component.ts | 75 -- .../models/iot-hub/iot-hub-item.models.ts | 5 + .../models/iot-hub/iot-hub-version.models.ts | 6 +- ui-ngx/src/app/shared/shared.module.ts | 3 - .../assets/locale/locale.constant-en_US.json | 14 + ui-ngx/src/assets/widget/all-widgets.svg | 7 + .../assets/widget/installed-from-iot-hub.svg | 9 + 21 files changed, 1072 insertions(+), 754 deletions(-) delete mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html delete mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss delete mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts delete mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-search.component.html delete mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss delete mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts create mode 100644 ui-ngx/src/assets/widget/all-widgets.svg create mode 100644 ui-ngx/src/assets/widget/installed-from-iot-hub.svg diff --git a/ui-ngx/src/app/core/http/iot-hub-api.service.ts b/ui-ngx/src/app/core/http/iot-hub-api.service.ts index 31367ffef9..f6d10b330a 100644 --- a/ui-ngx/src/app/core/http/iot-hub-api.service.ts +++ b/ui-ngx/src/app/core/http/iot-hub-api.service.ts @@ -23,7 +23,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.models'; import { IotHubInstalledItem, InstallItemVersionResult, UpdateItemVersionResult, ItemPublishedVersionInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; -import { ItemType, ItemTypeFilterInfo } from '@shared/models/iot-hub/iot-hub-item.models'; +import { ItemType, ItemTypeFilterInfo, WidgetCategory } from '@shared/models/iot-hub/iot-hub-item.models'; import { InterceptorHttpParams } from '@core/interceptors/interceptor-http-params'; import { InterceptorConfig } from '@core/interceptors/interceptor-config'; import { AppState } from '@core/core.state'; @@ -92,6 +92,22 @@ export class IotHubApiService { ); } + public getWidgetCategories(textSearch?: string, scadaFirst?: boolean, + config?: IotHubRequestConfig): Observable { + let url = `${this.baseUrl}/api/item-listing/widgetCategories`; + const queryParams: string[] = []; + if (textSearch?.trim()) { + queryParams.push(`textSearch=${encodeURIComponent(textSearch.trim())}`); + } + if (scadaFirst != null) { + queryParams.push(`scadaFirst=${scadaFirst}`); + } + if (queryParams.length) { + url += `?${queryParams.join('&')}`; + } + return this.http.get(url, { params: this.buildParams(config) }); + } + public getVersionInfo(versionId: string, config?: IotHubRequestConfig): Observable { return this.http.get( `${this.baseUrl}/api/versions/${versionId}`, diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 32c315fcf8..55014406d6 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -177,7 +177,6 @@ import * as HistorySelectorComponent from '@shared/components/time/history-selec import * as EntityGatewaySelectComponent from '@shared/components/entity/entity-gateway-select.component'; import * as ContactComponent from '@shared/components/contact.component'; import * as OtaPackageAutocompleteComponent from '@shared/components/ota-package/ota-package-autocomplete.component'; -import * as WidgetsBundleSearchComponent from '@shared/components/widgets-bundle-search.component'; import * as CopyButtonComponent from '@shared/components/button/copy-button.component'; import * as TogglePasswordComponent from '@shared/components/button/toggle-password.component'; import * as ProtobufContentComponent from '@shared/components/protobuf-content.component'; @@ -322,7 +321,6 @@ import * as DashboardStateDialogComponent from '@home/components/dashboard-page/ import * as EmbedDashboardDialogComponent from '@home/components/widget/dialog/embed-dashboard-dialog.component'; import * as EdgeDownlinkTableComponent from '@home/components/edge/edge-downlink-table.component'; import * as EdgeDownlinkTableHeaderComponent from '@home/components/edge/edge-downlink-table-header.component'; -import * as DisplayWidgetTypesPanelComponent from '@home/components/dashboard-page/widget-types-panel.component'; import * as AlarmDurationPredicateValueComponent from '@home/components/profile/alarm/alarm-duration-predicate-value.component'; import * as DashboardImageDialogComponent from '@home/components/dashboard-page/dashboard-image-dialog.component'; import * as WidgetContainerComponent from '@home/components/widget/widget-container.component'; @@ -524,7 +522,6 @@ class ModulesMap implements IModulesMap { '@shared/components/entity/entity-gateway-select.component': EntityGatewaySelectComponent, '@shared/components/contact.component': ContactComponent, '@shared/components/ota-package/ota-package-autocomplete.component': OtaPackageAutocompleteComponent, - '@shared/components/widgets-bundle-search.component': WidgetsBundleSearchComponent, '@shared/components/button/copy-button.component': CopyButtonComponent, '@shared/components/button/toggle-password.component': TogglePasswordComponent, '@shared/components/protobuf-content.component': ProtobufContentComponent, @@ -671,7 +668,6 @@ class ModulesMap implements IModulesMap { '@home/components/widget/dialog/embed-dashboard-dialog.component': EmbedDashboardDialogComponent, '@home/components/edge/edge-downlink-table.component': EdgeDownlinkTableComponent, '@home/components/edge/edge-downlink-table-header.component': EdgeDownlinkTableHeaderComponent, - '@home/components/dashboard-page/widget-types-panel.component': DisplayWidgetTypesPanelComponent, '@home/components/profile/alarm/alarm-duration-predicate-value.component': AlarmDurationPredicateValueComponent, '@home/components/dashboard-page/dashboard-image-dialog.component': DashboardImageDialogComponent, '@home/components/widget/widget-container.component': WidgetContainerComponent, 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 9b82a0ec12..dec624e0c2 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 @@ -400,95 +400,15 @@ (applyWidgetConfig)="saveWidget()"> - -
- -
-
- - -
-
- - - {{ 'widgets-bundle.widgets-bundles' | translate }} - {{ 'widget.all-widgets' | translate }} - {{ 'iot-hub.iot-hub' | translate }} - - - - {{ 'widget.all' | translate }} - {{ 'widget.actual' | translate }} - {{ 'widget.deprecated' | translate }} - - - - {{ 'widget.all' | translate }} - {{ 'iot-hub.installed' | translate }} - -
- - -
+ + diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss index 7b0d56b2e6..b1383603e0 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss @@ -195,3 +195,4 @@ div.tb-dashboard-page { } } + diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 024282e501..bf46d0b603 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -30,7 +30,6 @@ import { OnInit, Optional, Renderer2, - StaticProvider, ViewChild, ViewContainerRef, ViewEncapsulation, @@ -79,7 +78,6 @@ import { WidgetConfig, WidgetInfo, WidgetPosition, - widgetType, widgetTypesData } from '@shared/models/widget.models'; import { environment as env } from '@env/environment'; @@ -127,13 +125,6 @@ import { ImportExportService } from '@shared/import-export/import-export.service import { AuthState } from '@app/core/auth/auth.models'; import { FiltersDialogComponent, FiltersDialogData } from '@home/components/filter/filters-dialog.component'; import { Filters } from '@shared/models/query/query.models'; -import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; -import { ComponentPortal } from '@angular/cdk/portal'; -import { - DISPLAY_WIDGET_TYPES_PANEL_DATA, - DisplayWidgetTypesPanelComponent, - DisplayWidgetTypesPanelData -} from '@home/components/dashboard-page/widget-types-panel.component'; import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; import { MobileService } from '@core/services/mobile.service'; @@ -245,7 +236,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC forceDashboardMobileMode = false; isAddingWidget = false; isAddingWidgetClosed = true; - filterWidgetTypes: widgetType[] = null; isToolbarOpened = false; isToolbarOpenedAnimate = false; @@ -379,7 +369,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC private renderer: Renderer2, private ngZone: NgZone, @Optional() @Inject('embeddedValue') private embeddedValue, - private overlay: Overlay, private viewContainerRef: ViewContainerRef, private cd: ChangeDetectorRef, public elRef: ElementRef, @@ -1681,59 +1670,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC return widgetContextActions; } - clearSelectedWidgetBundle() { - this.dashboardWidgetSelectComponent.search = ''; - this.dashboardWidgetSelectComponent.widgetsBundle = null; - this.dashboardWidgetSelectComponent.selectWidgetMode = 'bundles'; - } - - editWidgetsTypesToDisplay($event: Event) { - if ($event) { - $event.stopPropagation(); - } - const target = $event.target || $event.currentTarget; - const config = new OverlayConfig(); - config.backdropClass = 'cdk-overlay-transparent-backdrop'; - config.hasBackdrop = true; - const connectedPosition: ConnectedPosition = { - originX: 'end', - originY: 'bottom', - overlayX: 'end', - overlayY: 'top' - }; - config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) - .withPositions([connectedPosition]); - - const overlayRef = this.overlay.create(config); - overlayRef.backdropClick().subscribe(() => { - overlayRef.dispose(); - }); - - const filterWidgetTypes = this.dashboardWidgetSelectComponent.filterWidgetTypes; - const widgetTypesList = Array.from(this.dashboardWidgetSelectComponent.widgetTypes.values()).map(type => - ({type, display: filterWidgetTypes === null ? true : filterWidgetTypes.includes(type)})); - - const providers: StaticProvider[] = [ - { - provide: DISPLAY_WIDGET_TYPES_PANEL_DATA, - useValue: { - types: widgetTypesList, - typesUpdated: (newTypes) => { - this.filterWidgetTypes = newTypes.filter(type => type.display).map(type => type.type); - this.cd.markForCheck(); - } - } as DisplayWidgetTypesPanelData - }, - { - provide: OverlayRef, - useValue: overlayRef - } - ]; - const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); - overlayRef.attach(new ComponentPortal(DisplayWidgetTypesPanelComponent, this.viewContainerRef, injector)); - this.cd.markForCheck(); - } - public updateDashboardImage($event: Event) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html index 16ebbdf8f7..77029df41d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html @@ -16,19 +16,227 @@ -->
- - - - - - + +
+ @if (showTopLevelWidgetModeToggle()) { + + {{ 'widget.installed' | translate }} + {{ 'iot-hub.iot-hub' | translate }} + + } @else if (showBackButton()) { + + {{ getHeaderTitle() }} + } + + @if (selectWidgetMode === 'installed') { + + } + +
+
+ + @if (isFilterVisible()) { + + } + @if (selectWidgetMode === 'iotHub' && iotHubSubMode !== 'default') { + + + @for (option of iotHubSortOptions; track option.value; let i = $index) { + + } + + } +
+
+
+ @switch (selectWidgetMode) { + @case ('installed') { + @if (widgetsBundle) { - - - - - - + } @else if (installedSubMode === 'allWidgets') { + + } @else { + + + + @switch (item.__logical) { + @case (LOGICAL_ALL_WIDGETS) { + +
+
{{ 'widget.all-widgets' | translate }}
+
+
+
+ {{ 'widget.all-widgets' | translate }} +
+
+ } + @default { + +
+
+ {{item.title}} +
+
+ @if (isSystem(item)) { +
sys
+ } +
i
+
+
+
+
+ {{ item.title }} +
+
+ } + } +
+ +
+ + {{ 'widgets-bundle.loading-widgets-bundles' | translate }} + + +
+
+ + widgets-bundle.no-widgets-bundles-text + + } + } + @case ('iotHub') { + @switch (iotHubSubMode) { + @case ('default') { + + + + @switch (item.__logical) { + @case (LOGICAL_ALL_WIDGETS) { + +
+
{{ 'widget.all-widgets' | translate }}
+
+
+
+ {{ 'widget.all-widgets' | translate }} +
+
+ } + @case (LOGICAL_INSTALLED_FROM_IOT_HUB) { + +
+
{{ 'iot-hub.installed-from-iot-hub' | translate }}
+
+
+
+ {{ 'iot-hub.installed-from-iot-hub' | translate }} +
+
+ } + @default { + +
+
{{ item.name }}
+
+
+
+ @if (getCategoryImageUrl(item); as imgUrl) { + {{ item.name }} + } @else { + {{ item.name }} + } +
+
+ } + } +
+ +
+ +
+
+ + {{ 'iot-hub.no-items-found' | translate }} + + } + @case ('installed') { + + } + @default { + + } + } + } + } +
+
{{ 'widget.' + item.widgetType + '-short' | translate }}
-
i
+
i
-
widget.deprecated
+ @if (item.deprecated) { +
widget.deprecated
+ }
@@ -69,119 +279,62 @@
- {{(selectWidgetMode === 'bundles' ? 'widgets-bundle.empty' : 'widget.no-widgets-text') | translate}} + {{ 'widget.no-widgets-text' | translate }} - + + +
+
+
+
+
+
+
+ + + [dataLoading]="loadingIotHubWidgets" + [noData]="noIotHubWidgets"> - - -
-
- {{item.title}} -
-
-
sys
-
i
-
-
-
-
- {{ item.title }} -
-
+ + - +
- {{ 'widgets-bundle.loading-widgets-bundles' | translate }} + {{ 'widget.loading-widgets' | translate }} - +
- - widgets-bundle.no-widgets-bundles-text + + {{ 'iot-hub.no-items-found' | translate }} - - -
-
-
-
-
-
-
- - - - - - - - - - - -
-
- {{item.name}} -
-
-
{{ 'widget.' + item.dataDescriptor.widgetType + '-short' | translate }}
-
i
-
-
-
-
- {{ item.name }} - {{ item.name }} -
- -
+ + + + + - +
{{ 'widget.loading-widgets' | translate }} @@ -189,139 +342,222 @@
- + {{ 'iot-hub.no-items-found' | translate }} + + +
+
+ {{item.name}} +
+
+ @if (item.dataDescriptor?.widgetType) { +
{{ 'widget.' + item.dataDescriptor.widgetType + '-short' | translate }}
+ } +
i
+
+
+
+
+ @if (getIotHubItemImage(item); as imgUrl) { + {{ item.name }} + } @else { + {{ item.name }} + } +
+ +
+
+
{{ 'iot-hub.filters' | translate }} + + @if (hasActiveFilters()) { + + } +
- - - - {{ 'iot-hub.type' | translate }} - @if (iotHubPendingWidgetTypes.size) { - {{ iotHubPendingWidgetTypes.size }} - } - - - @if (iotHubWidgetTypeOptions.length >= 10) { - - } -
- @for (opt of getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType'); track opt.key) { -
- - {{ getIotHubWidgetTypeLabel(opt.key) }} - + @if (selectWidgetMode === 'installed') { + @if (widgetTypes.size > 1) { + + + + {{ 'iot-hub.type' | translate }} + @if (filterWidgetTypes?.length) { + {{ filterWidgetTypes.length }} + } + + +
+ @for (type of widgetTypesList; track type) { +
+ + {{ getWidgetTypeLabel(type) }} + +
+ }
+
+ } + @if (hasDeprecated) { + @if (widgetTypes.size > 1) { + } -
- - - - - - {{ 'iot-hub.category' | translate }} - @if (iotHubPendingCategories.size) { - {{ iotHubPendingCategories.size }} - } - - - @if (iotHubCategoryOptions.length >= 10) { - 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 4137f195f1..f69cd35eca 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 @@ -13,10 +13,145 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +tb-dashboard-widget-select { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + .tb-dashboard-widget-select { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; background-color: #cfd8dc; - position: absolute; - inset: 0; + + mat-toolbar.tb-widget-select-header { + flex-shrink: 0; + width: 100%; + height: auto; + gap: 16px; + color: #fff; + padding: 12px 12px 12px 24px; + + button.mat-mdc-icon-button.mat-mdc-button-base { + --mdc-icon-button-state-layer-size: 40px; + --mat-icon-button-state-layer-size: 40px; + --mdc-icon-button-icon-color: #fff; + padding: 8px; + color: #fff; + } + } + + .tb-widget-select-title-row { + gap: 8px; + width: 100%; + min-height: 40px; + + .tb-widget-select-title { + font-size: 24px; + line-height: 32px; + font-weight: 400; + color: #fff; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; + } + } + + .tb-widget-select-search-row { + width: 100%; + gap: 8px; + + .tb-widget-select-search { + height: 40px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.16); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + caret-color: #00695c; + color: rgba(255, 255, 255, 0.84); + + mat-icon { + color: rgba(255, 255, 255, 0.8); + } + + input { + width: 100%; + height: 100%; + min-width: 0; + padding: 0; + font-size: 16px; + line-height: 24px; + background: transparent; + color: #fff; + border: none; + outline: none; + + &::placeholder { + color: rgba(255, 255, 255, 0.8); + opacity: 1; + } + } + + button.mat-mdc-icon-button mat-icon { + color: rgba(255, 255, 255, 0.8); + } + + &.focus { + background: #fff; + border-color: rgba(255, 255, 255, 0.15); + caret-color: #00695c; + + mat-icon { + color: rgba(0, 0, 0, 0.54); + } + + input { + color: rgba(0, 0, 0, 0.76); + opacity: 1; + + &::placeholder { + color: rgba(0, 0, 0, 0.38); + } + } + + button.mat-mdc-icon-button mat-icon, + mat-icon.close { + color: rgba(0, 0, 0, 0.54) !important; + opacity: 1; + } + } + } + + .tb-widget-select-sort { + color: #fff; + font-weight: 500; + font-size: 14px; + letter-spacing: 0.25px; + + mat-icon { + color: #fff; + } + } + } + + .tb-widget-select-body { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + } + + .tb-widget-logical-card, + .tb-widget-category-card { + .widget-title { + color: rgba(0, 0, 0, 0.87); + font-weight: 600; + } + } .mat-mdc-card.tb-widget-preview-card { cursor: pointer; @@ -163,6 +298,10 @@ width: 16px; height: 16px; color: rgba(0, 0, 0, 0.54); + + &.tb-iot-hub-verified-icon { + color: #00695c; + } } } @@ -202,7 +341,6 @@ // IoT Hub filter panel .tb-iot-hub-filter-panel { - padding: 12px 8px; display: flex; flex-direction: column; gap: 12px; @@ -211,7 +349,21 @@ .tb-iot-hub-filter-header { display: flex; align-items: center; - justify-content: space-between; + gap: 8px; + } + + .tb-wsp-clear-all.mat-mdc-outlined-button { + --mat-button-outlined-outline-color: rgba(0, 0, 0, 0.12); + height: 40px; + line-height: 20px; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.25px; + padding: 0 16px; + } + + .tb-wsp-close.mat-mdc-icon-button { + color: rgba(0, 0, 0, 0.54); } .tb-iot-hub-filter-title { @@ -395,6 +547,11 @@ gap: 8px; padding-top: 8px; } + + .tb-widget-select-include-deprecated { + padding: 8px 8px 4px; + } + } @keyframes shine { 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 4b5789558b..4116b38b65 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, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, 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'; @@ -37,7 +37,7 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { MatDialog } from '@angular/material/dialog'; import { TranslateService } from '@ngx-translate/core'; import { MpItemVersionQuery, MpItemVersionView, widgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; -import { ItemType, FilterParamInfo } from '@shared/models/iot-hub/iot-hub-item.models'; +import { ItemType, FilterParamInfo, WidgetCategory } 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 { @@ -45,7 +45,22 @@ import { IotHubItemDetailDialogData } from '@home/components/iot-hub/iot-hub-item-detail-dialog.component'; -type selectWidgetMode = 'bundles' | 'allWidgets' | 'iotHub'; +type selectWidgetMode = 'installed' | 'iotHub'; +type installedSubMode = 'default' | 'allWidgets'; +type iotHubSubMode = 'default' | 'allWidgets' | 'installed' | 'category'; + +interface WidgetSelectSortOption { + value: string; + label: string; + direction: Direction; +} + +const LOGICAL_ALL_WIDGETS = '__logical_all_widgets__'; +const LOGICAL_INSTALLED_FROM_IOT_HUB = '__logical_installed_from_iot_hub__'; + +interface LogicalCard { + __logical: typeof LOGICAL_ALL_WIDGETS | typeof LOGICAL_INSTALLED_FROM_IOT_HUB; +} interface WidgetsFilter { search: string; @@ -64,7 +79,7 @@ interface BundleWidgetsFilter extends WidgetsFilter { encapsulation: ViewEncapsulation.None, standalone: false }) -export class DashboardWidgetSelectComponent implements OnInit { +export class DashboardWidgetSelectComponent { private searchSubject = new BehaviorSubject(''); private search$ = this.searchSubject.asObservable().pipe( @@ -72,12 +87,25 @@ export class DashboardWidgetSelectComponent implements OnInit { private filterWidgetTypes$ = new BehaviorSubject>(null); private deprecatedFilter$ = new BehaviorSubject(DeprecatedFilter.ACTUAL); - private selectWidgetMode$ = new BehaviorSubject('bundles'); + private selectWidgetMode$ = new BehaviorSubject('installed'); + private installedSubMode$ = new BehaviorSubject('default'); + private iotHubSubMode$ = new BehaviorSubject('default'); private widgetsBundle$ = new BehaviorSubject(null); widgetTypes = new Set(); hasDeprecated = false; + iotHubSelectedCategory: string = null; + includeDeprecated = false; + searchFocused = false; + + iotHubSortOptions: WidgetSelectSortOption[] = [ + { 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 } + ]; + iotHubSelectedSortIndex = 0; + @Input() aliasController: IAliasController; @@ -94,7 +122,6 @@ export class DashboardWidgetSelectComponent implements OnInit { return this.searchSubject.value; } - @Input() set filterWidgetTypes(widgetTypes: Array) { this.filterWidgetTypes$.next(widgetTypes); } @@ -106,25 +133,21 @@ export class DashboardWidgetSelectComponent implements OnInit { @Input() set selectWidgetMode(mode: selectWidgetMode) { if (this.selectWidgetMode$.value !== mode) { - if (mode === 'bundles' && this.widgetsBundle$.value === null) { - this.widgetTypes.clear(); - this.hasDeprecated = false; - } else { - this.widgetTypes = new Set(Object.keys(widgetType).map(t => t as widgetType)); - this.hasDeprecated = true; - } + this.resetSubModes(); this.filterWidgetTypes$.next(null); this.deprecatedFilter$.next(DeprecatedFilter.ACTUAL); + this.includeDeprecated = false; this.selectWidgetMode$.next(mode); + this.updateWidgetTypesForCurrentState(); if (mode === 'iotHub') { this.loadInstalledWidgets(); this.loadIotHubFilterInfo(); + this.loadWidgetCategories(); } else { - this.iotHubInstalledMode = 'all'; this.installedWidgetVersions = null; - this.appliedWidgetTypes.clear(); - this.appliedCategories.clear(); - this.appliedUseCases.clear(); + this.iotHubAppliedWidgetTypes.clear(); + this.iotHubAppliedCategories.clear(); + this.iotHubAppliedUseCases.clear(); this.iotHubFilterCount = 0; } } @@ -134,6 +157,40 @@ export class DashboardWidgetSelectComponent implements OnInit { return this.selectWidgetMode$.value; } + set installedSubMode(sub: installedSubMode) { + if (this.installedSubMode$.value !== sub) { + this.installedSubMode$.next(sub); + this.filterWidgetTypes$.next(null); + this.deprecatedFilter$.next(DeprecatedFilter.ACTUAL); + this.includeDeprecated = false; + this.updateWidgetTypesForCurrentState(); + } + } + + get installedSubMode(): installedSubMode { + return this.installedSubMode$.value; + } + + set iotHubSubMode(sub: iotHubSubMode) { + if (this.iotHubSubMode$.value !== sub) { + this.iotHubSubMode$.next(sub); + if (sub !== 'category') { + this.iotHubSelectedCategory = null; + } + this.installedWidgetVersions = null; + if (sub === 'default') { + this.widgetCategoriesFilter = (this.searchSubject.value || '') + '|' + Date.now(); + this.cd.markForCheck(); + } else { + this.reloadIotHubWidgets(); + } + } + } + + get iotHubSubMode(): iotHubSubMode { + return this.iotHubSubMode$.value; + } + @Input() set deprecatedFilter(filter: DeprecatedFilter) { this.deprecatedFilter$.next(filter); @@ -145,7 +202,7 @@ export class DashboardWidgetSelectComponent implements OnInit { set widgetsBundle(widgetBundle: WidgetsBundle) { if (this.widgetsBundle$.value !== widgetBundle) { - if (widgetBundle === null && this.selectWidgetMode$.value !== 'allWidgets') { + if (widgetBundle === null) { this.widgetTypes.clear(); this.hasDeprecated = false; } else { @@ -167,6 +224,12 @@ export class DashboardWidgetSelectComponent implements OnInit { @Output() widgetSelected: EventEmitter = new EventEmitter(); + @Output() + closeClick: EventEmitter = new EventEmitter(); + + @Output() + importClick: EventEmitter = new EventEmitter(); + columns: ScrollGridColumns = { columns: 2, breakpoints: { @@ -184,36 +247,37 @@ export class DashboardWidgetSelectComponent implements OnInit { }; widgetBundlesFetchFunction: GridEntitiesFetchFunction; + installedDefaultFetchFunction: GridEntitiesFetchFunction; allWidgetsFetchFunction: GridEntitiesFetchFunction; widgetsFetchFunction: GridEntitiesFetchFunction; iotHubWidgetsFetchFunction: GridEntitiesFetchFunction; + iotHubInstalledWidgetsFetchFunction: GridEntitiesFetchFunction; + iotHubDefaultFetchFunction: GridEntitiesFetchFunction; + + readonly LOGICAL_ALL_WIDGETS = LOGICAL_ALL_WIDGETS; + readonly LOGICAL_INSTALLED_FROM_IOT_HUB = LOGICAL_INSTALLED_FROM_IOT_HUB; widgetsBundleFilter = ''; 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 = ''; + widgetCategoriesFilter = ''; - // IoT Hub filter model — applied state (used by fetch functions) - private appliedWidgetTypes = new Set(); - private appliedCategories = new Set(); - private appliedUseCases = new Set(); + private installedWidgets: IotHubInstalledItem[] = null; + private installedWidgetVersions: MpItemVersionView[] = null; - // IoT Hub filter model — pending state (used by filter panel UI) - iotHubPendingWidgetTypes = new Set(); - iotHubPendingCategories = new Set(); - iotHubPendingUseCases = new Set(); - iotHubFilterDirty = false; + // IoT Hub filter model — applied state (used by fetch functions + panel UI) + iotHubAppliedWidgetTypes = new Set(); + iotHubAppliedCategories = new Set(); + iotHubAppliedUseCases = new Set(); iotHubWidgetTypeOptions: FilterParamInfo[] = []; iotHubCategoryOptions: FilterParamInfo[] = []; iotHubUseCaseOptions: FilterParamInfo[] = []; iotHubFilterCount = 0; iotHubFilterSearch: Record = {}; + iotHubFilterItemsHovered = false; constructor(private widgetsService: WidgetService, private iotHubApiService: IotHubApiService, @@ -249,14 +313,24 @@ 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 sort = this.iotHubSortOptions[this.iotHubSelectedSortIndex]; + const sortOrder: SortOrder = { property: sort.value, direction: sort.direction }; const pageLink = new PageLink(pageSize, page, search || null, sortOrder); + const effectiveCategories = this.iotHubSelectedCategory + ? [this.iotHubSelectedCategory] + : (this.iotHubAppliedCategories.size > 0 ? Array.from(this.iotHubAppliedCategories) : undefined); const query = new MpItemVersionQuery(pageLink, ItemType.WIDGET, undefined, undefined, - this.appliedCategories.size > 0 ? Array.from(this.appliedCategories) : undefined, - this.appliedUseCases.size > 0 ? Array.from(this.appliedUseCases) : undefined, + effectiveCategories, + this.iotHubAppliedUseCases.size > 0 ? Array.from(this.iotHubAppliedUseCases) : undefined, + undefined, + this.iotHubAppliedWidgetTypes.size > 0 ? Array.from(this.iotHubAppliedWidgetTypes) : undefined, + undefined, + undefined, + undefined, undefined, - this.appliedWidgetTypes.size > 0 ? Array.from(this.appliedWidgetTypes) : undefined + undefined, + this.scadaFirst ? true : undefined ); return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true }); }; @@ -270,6 +344,29 @@ export class DashboardWidgetSelectComponent implements OnInit { return of(this.filterAndPaginateInstalledVersions(this.installedWidgetVersions, pageSize, page, filter)); }; + this.installedDefaultFetchFunction = (pageSize, page, filter) => { + return this.widgetBundlesFetchFunction(pageSize, page, filter).pipe( + map(pd => this.prependLogicalCards(pd, page, [{ __logical: LOGICAL_ALL_WIDGETS }])) + ); + }; + + this.iotHubDefaultFetchFunction = (pageSize, page, filter) => { + const search = typeof filter === 'string' ? filter.split('|')[0] : filter; + return this.iotHubApiService.getWidgetCategories(search || undefined, + this.scadaFirst ? true : undefined, { ignoreLoading: true }).pipe( + map(categories => ({ + data: categories.slice(page * pageSize, page * pageSize + pageSize), + totalPages: Math.ceil(categories.length / pageSize), + totalElements: categories.length, + hasNext: (page + 1) * pageSize < categories.length + })), + map(pd => this.prependLogicalCards(pd, page, [ + { __logical: LOGICAL_ALL_WIDGETS }, + { __logical: LOGICAL_INSTALLED_FROM_IOT_HUB } + ])) + ); + }; + this.search$.pipe( distinctUntilChanged(), skip(1) @@ -277,8 +374,10 @@ export class DashboardWidgetSelectComponent implements OnInit { (search) => { this.widgetsBundleFilter = search; if (this.selectWidgetMode$.value === 'iotHub') { - if (this.iotHubInstalledMode === 'installed') { + if (this.iotHubSubMode$.value === 'installed') { this.iotHubInstalledWidgetsFilter = search; + } else if (this.iotHubSubMode$.value === 'default') { + this.widgetCategoriesFilter = search; } else { this.iotHubWidgetsFilter = search; } @@ -312,7 +411,36 @@ export class DashboardWidgetSelectComponent implements OnInit { ); } - ngOnInit(): void { + private prependLogicalCards(pageData: any, page: number, cards: LogicalCard[]): any { + if (page === 0 && cards.length) { + return { + ...pageData, + data: [...cards, ...(pageData.data || [])], + totalElements: (pageData.totalElements || 0) + cards.length + }; + } + return pageData; + } + + private resetSubModes(): void { + this.widgetsBundle$.next(null); + this.installedSubMode$.next('default'); + this.iotHubSubMode$.next('default'); + this.iotHubSelectedCategory = null; + } + + private updateWidgetTypesForCurrentState(): void { + const mode = this.selectWidgetMode$.value; + const showTypeFilter = + (mode === 'installed' && (this.installedSubMode$.value === 'allWidgets' || this.widgetsBundle$.value !== null)) || + (mode === 'iotHub' && this.iotHubSubMode$.value !== 'default'); + if (showTypeFilter) { + this.widgetTypes = new Set(Object.keys(widgetType).map(t => t as widgetType)); + this.hasDeprecated = mode === 'installed'; + } else { + this.widgetTypes.clear(); + this.hasDeprecated = false; + } } onWidgetClicked($event: Event, widget: WidgetTypeInfo): void { @@ -326,10 +454,33 @@ export class DashboardWidgetSelectComponent implements OnInit { selectBundle($event: Event, bundle: WidgetsBundle) { $event.preventDefault(); this.widgetsBundle = bundle; - if (bundle.title?.toLowerCase().includes(this.search.toLowerCase()) || - bundle.description?.toLowerCase().includes(this.search.toLowerCase())) { - this.searchSubject.next(''); + } + + onAllWidgetsCardClicked($event: Event): void { + $event.preventDefault(); + if (this.selectWidgetMode$.value === 'installed') { + this.installedSubMode = 'allWidgets'; + } else { + this.iotHubSubMode = 'allWidgets'; + } + } + + onInstalledFromIotHubCardClicked($event: Event): void { + $event.preventDefault(); + this.iotHubSubMode = 'installed'; + } + + onCategoryCardClicked($event: Event, category: WidgetCategory): void { + $event.preventDefault(); + this.iotHubSelectedCategory = category.name; + this.iotHubSubMode = 'category'; + } + + getCategoryImageUrl(category: WidgetCategory): string | null { + if (!category?.image) { + return null; } + return this.iotHubApiService.resolveResourceUrl(category.image); } onIotHubWidgetClicked($event: Event, item: MpItemVersionView): void { @@ -383,27 +534,6 @@ export class DashboardWidgetSelectComponent implements OnInit { return null; } - onIotHubInstalledModeChange(mode: 'all' | 'installed'): void { - this.iotHubInstalledMode = mode; - const search = this.searchSubject.value; - if (mode === 'installed') { - this.installedWidgetVersions = null; - this.iotHubInstalledWidgetsFilter = search; - } else { - this.iotHubWidgetsFilter = search; - } - this.cd.markForCheck(); - } - - openIotHubFilterPanel(): void { - this.iotHubPendingWidgetTypes = new Set(this.appliedWidgetTypes); - this.iotHubPendingCategories = new Set(this.appliedCategories); - this.iotHubPendingUseCases = new Set(this.appliedUseCases); - this.iotHubFilterDirty = false; - } - - iotHubFilterItemsHovered = false; - getIotHubWidgetTypeLabel(key: string): string { const translationKey = widgetTypeTranslations.get(key); return translationKey ? this.translate.instant(translationKey) : key; @@ -436,66 +566,196 @@ export class DashboardWidgetSelectComponent implements OnInit { } toggleIotHubWidgetType(key: string): void { - if (this.iotHubPendingWidgetTypes.has(key)) { - this.iotHubPendingWidgetTypes.delete(key); + if (this.iotHubAppliedWidgetTypes.has(key)) { + this.iotHubAppliedWidgetTypes.delete(key); } else { - this.iotHubPendingWidgetTypes.add(key); + this.iotHubAppliedWidgetTypes.add(key); } - this.updateFilterDirty(); + this.onIotHubFiltersChanged(); } toggleIotHubCategory(key: string): void { - if (this.iotHubPendingCategories.has(key)) { - this.iotHubPendingCategories.delete(key); + if (this.iotHubAppliedCategories.has(key)) { + this.iotHubAppliedCategories.delete(key); } else { - this.iotHubPendingCategories.add(key); + this.iotHubAppliedCategories.add(key); } - this.updateFilterDirty(); + this.onIotHubFiltersChanged(); } toggleIotHubUseCase(key: string): void { - if (this.iotHubPendingUseCases.has(key)) { - this.iotHubPendingUseCases.delete(key); + if (this.iotHubAppliedUseCases.has(key)) { + this.iotHubAppliedUseCases.delete(key); } else { - this.iotHubPendingUseCases.add(key); + this.iotHubAppliedUseCases.add(key); } - this.updateFilterDirty(); + this.onIotHubFiltersChanged(); } clearIotHubFilters(): void { - this.iotHubPendingWidgetTypes.clear(); - this.iotHubPendingCategories.clear(); - this.iotHubPendingUseCases.clear(); + this.iotHubAppliedWidgetTypes.clear(); + this.iotHubAppliedCategories.clear(); + this.iotHubAppliedUseCases.clear(); this.iotHubFilterSearch = {}; - this.updateFilterDirty(); + this.onIotHubFiltersChanged(); } - applyIotHubFilters(): void { - this.appliedWidgetTypes = new Set(this.iotHubPendingWidgetTypes); - this.appliedCategories = new Set(this.iotHubPendingCategories); - this.appliedUseCases = new Set(this.iotHubPendingUseCases); - this.iotHubFilterCount = this.appliedWidgetTypes.size + this.appliedCategories.size + this.appliedUseCases.size; - this.iotHubFilterDirty = false; + private onIotHubFiltersChanged(): void { + this.iotHubFilterCount = + this.iotHubAppliedWidgetTypes.size + + (this.iotHubSelectedCategory ? 0 : this.iotHubAppliedCategories.size) + + this.iotHubAppliedUseCases.size; this.reloadIotHubWidgets(); } - private updateFilterDirty(): void { - this.iotHubFilterDirty = - !this.setsEqual(this.iotHubPendingWidgetTypes, this.appliedWidgetTypes) || - !this.setsEqual(this.iotHubPendingCategories, this.appliedCategories) || - !this.setsEqual(this.iotHubPendingUseCases, this.appliedUseCases); + onIotHubSortChange(index: number): void { + if (this.iotHubSelectedSortIndex !== index) { + this.iotHubSelectedSortIndex = index; + this.installedWidgetVersions = null; + this.reloadIotHubWidgets(); + } + } + + get totalFilterCount(): number { + if (this.selectWidgetMode === 'installed') { + return (this.filterWidgetTypes?.length ?? 0) + (this.includeDeprecated ? 1 : 0); + } + return this.iotHubFilterCount; + } + + hasActiveFilters(): boolean { + if (this.selectWidgetMode === 'installed') { + return (this.filterWidgetTypes?.length ?? 0) > 0 || this.includeDeprecated; + } + return this.iotHubAppliedWidgetTypes.size > 0 + || this.iotHubAppliedCategories.size > 0 + || this.iotHubAppliedUseCases.size > 0; + } + + clearAllFilters(): void { + if (this.selectWidgetMode === 'installed') { + this.filterWidgetTypes = null; + this.onIncludeDeprecatedChange(false); + } else { + this.clearIotHubFilters(); + } + } + + get hideCategoriesFilterSection(): boolean { + return this.selectWidgetMode === 'iotHub' && this.iotHubSubMode === 'category'; + } + + onIncludeDeprecatedChange(value: boolean): void { + this.includeDeprecated = value; + this.deprecatedFilter = value ? DeprecatedFilter.ALL : DeprecatedFilter.ACTUAL; + } + + isFilterVisible(): boolean { + if (this.selectWidgetMode === 'iotHub') { + return this.iotHubSubMode !== 'default'; + } + return this.installedSubMode === 'allWidgets' || this.widgetsBundle !== null; + } + + showTopLevelWidgetModeToggle(): boolean { + if (this.selectWidgetMode === 'installed') { + return !this.widgetsBundle && this.installedSubMode === 'default'; + } + return this.iotHubSubMode === 'default'; + } + + showBackButton(): boolean { + if (this.selectWidgetMode === 'installed') { + return !!this.widgetsBundle || this.installedSubMode === 'allWidgets'; + } + return this.iotHubSubMode !== 'default'; + } + + getHeaderTitle(): string { + if (this.selectWidgetMode === 'iotHub') { + if (this.iotHubSubMode === 'category' && this.iotHubSelectedCategory) { + return this.iotHubSelectedCategory; + } + if (this.iotHubSubMode === 'installed') { + return this.translate.instant('iot-hub.installed-from-iot-hub'); + } + if (this.iotHubSubMode === 'allWidgets') { + return this.translate.instant('widget.all-widgets'); + } + return ''; + } + if (this.widgetsBundle) { + return this.widgetsBundle.title; + } + if (this.installedSubMode === 'allWidgets') { + return this.translate.instant('widget.all-widgets'); + } + return ''; } - private setsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) { return false; } - for (const v of a) { - if (!b.has(v)) { return false; } + getSearchPlaceholder(): string { + if (this.selectWidgetMode === 'iotHub') { + if (this.iotHubSubMode === 'default') { + return this.translate.instant('iot-hub.search-categories'); + } + return this.translate.instant('iot-hub.search-widgets'); } - return true; + if (this.installedSubMode === 'default' && !this.widgetsBundle) { + return this.translate.instant('iot-hub.search-bundles'); + } + return this.translate.instant('iot-hub.search-widgets'); + } + + onBackClick(): void { + if (this.selectWidgetMode === 'installed') { + if (this.widgetsBundle) { + this.widgetsBundle = null; + } else if (this.installedSubMode === 'allWidgets') { + this.installedSubMode = 'default'; + } + } else if (this.selectWidgetMode === 'iotHub') { + if (this.iotHubSubMode !== 'default') { + this.iotHubSubMode = 'default'; + } + } + } + + onClose(): void { + this.closeClick.emit(); + } + + clearSearch($event: Event): void { + $event.preventDefault(); + $event.stopPropagation(); + this.search = ''; + } + + onImport($event: Event): void { + this.importClick.emit($event); + } + + get widgetTypesList(): widgetType[] { + return Array.from(this.widgetTypes.values()); + } + + getWidgetTypeLabel(type: widgetType): string { + return this.translate.instant('widget.type-' + type); + } + + isWidgetTypeChecked(type: widgetType): boolean { + return !!this.filterWidgetTypes && this.filterWidgetTypes.includes(type); + } + + toggleWidgetTypeFilter(type: widgetType): void { + const current = this.filterWidgetTypes ?? []; + const next = current.includes(type) + ? current.filter(t => t !== type) + : [...current, type]; + this.filterWidgetTypes = next.length ? next : null; } private reloadIotHubWidgets(): void { - if (this.iotHubInstalledMode === 'installed') { + if (this.iotHubSubMode === 'installed') { this.installedWidgetVersions = null; this.iotHubInstalledWidgetsFilter = this.searchSubject.value + '|' + Date.now(); } else { @@ -536,6 +796,10 @@ export class DashboardWidgetSelectComponent implements OnInit { }); } + private loadWidgetCategories(): void { + this.widgetCategoriesFilter = this.searchSubject.value + '|' + Date.now(); + } + private loadInstalledWidgets(): void { if (this.installedWidgets === null) { this.installedWidgets = []; @@ -575,20 +839,46 @@ export class DashboardWidgetSelectComponent implements OnInit { if (search) { filtered = filtered.filter(v => v.name.toLowerCase().includes(search.toLowerCase())); } - if (this.appliedWidgetTypes.size > 0) { - filtered = filtered.filter(v => this.appliedWidgetTypes.has(v.dataDescriptor?.widgetType)); + if (this.iotHubAppliedWidgetTypes.size > 0) { + filtered = filtered.filter(v => this.iotHubAppliedWidgetTypes.has(v.dataDescriptor?.widgetType)); } - if (this.appliedCategories.size > 0) { - filtered = filtered.filter(v => v.categories?.some(c => this.appliedCategories.has(c))); + if (this.iotHubAppliedCategories.size > 0) { + filtered = filtered.filter(v => v.categories?.some(c => this.iotHubAppliedCategories.has(c))); } - if (this.appliedUseCases.size > 0) { - filtered = filtered.filter(v => v.useCases?.some(u => this.appliedUseCases.has(u))); + if (this.iotHubAppliedUseCases.size > 0) { + filtered = filtered.filter(v => v.useCases?.some(u => this.iotHubAppliedUseCases.has(u))); } + filtered = this.sortInstalledVersions(filtered); 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 sortInstalledVersions(versions: MpItemVersionView[]): MpItemVersionView[] { + const sort = this.iotHubSortOptions[this.iotHubSelectedSortIndex]; + const sign = sort.direction === Direction.ASC ? 1 : -1; + const copy = [...versions]; + copy.sort((a, b) => { + if (this.scadaFirst) { + const aScada = !!a.dataDescriptor?.scada; + const bScada = !!b.dataDescriptor?.scada; + if (aScada !== bScada) { + return aScada ? -1 : 1; + } + } + const av = (a as any)[sort.value]; + const bv = (b as any)[sort.value]; + if (av == null && bv == null) { return 0; } + if (av == null) { return 1; } + if (bv == null) { return -1; } + if (typeof av === 'string' && typeof bv === 'string') { + return sign * av.localeCompare(bv); + } + return sign * (av < bv ? -1 : av > bv ? 1 : 0); + }); + return copy; + } + private toWidgetInfo(widgetTypeInfo: WidgetTypeInfo): WidgetInfo { return { typeFullFqn: fullWidgetTypeFqn(widgetTypeInfo), diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html deleted file mode 100644 index 51eebbf7ed..0000000000 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html +++ /dev/null @@ -1,23 +0,0 @@ - -
- - {{ 'widget.' + type.type | translate }} - -
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss deleted file mode 100644 index 99285e46db..0000000000 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 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. - */ -:host { - width: 100%; - height: 100%; - min-width: 200px; - overflow: hidden; - background: #fff; - border-radius: 4px; - box-shadow: - 0 7px 8px -4px rgba(0, 0, 0, .2), - 0 13px 19px 2px rgba(0, 0, 0, .14), - 0 5px 24px 4px rgba(0, 0, 0, .12); - - .mat-content { - overflow: hidden; - background-color: #fff; - } - - .mat-padding { - padding: 16px; - } -} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts deleted file mode 100644 index cb43dce650..0000000000 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts +++ /dev/null @@ -1,49 +0,0 @@ -/// -/// 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, Inject, InjectionToken } from '@angular/core'; -import { widgetType } from '@shared/models/widget.models'; - -export const DISPLAY_WIDGET_TYPES_PANEL_DATA = new InjectionToken('DisplayWidgetTypesPanelData'); - -export interface WidgetTypes { - type: widgetType; - display: boolean; -} - -export interface DisplayWidgetTypesPanelData { - types: WidgetTypes[]; - typesUpdated: (columns: WidgetTypes[]) => void; -} - -@Component({ - selector: 'tb-widget-types-panel', - templateUrl: './widget-types-panel.component.html', - styleUrls: ['./widget-types-panel.component.scss'], - standalone: false -}) -export class DisplayWidgetTypesPanelComponent { - - types: WidgetTypes[]; - - constructor(@Inject(DISPLAY_WIDGET_TYPES_PANEL_DATA) public data: DisplayWidgetTypesPanelData) { - this.types = this.data.types; - } - - public update() { - this.data.typesUpdated(this.types); - } -} diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 79967cb4fa..a6c0310720 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -125,7 +125,6 @@ import { EmbedDashboardDialogComponent } from '@home/components/widget/dialog/em import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token'; import { EdgeDownlinkTableComponent } from '@home/components/edge/edge-downlink-table.component'; import { EdgeDownlinkTableHeaderComponent } from '@home/components/edge/edge-downlink-table-header.component'; -import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-page/widget-types-panel.component'; import { AlarmDurationPredicateValueComponent } from '@home/components/profile/alarm/alarm-duration-predicate-value.component'; import { DashboardImageDialogComponent } from '@home/components/dashboard-page/dashboard-image-dialog.component'; import { @@ -331,7 +330,6 @@ import { SolutionInstallDialogComponent } from '@home/components/solution/soluti DashboardStateDialogComponent, DashboardImageDialogComponent, EmbedDashboardDialogComponent, - DisplayWidgetTypesPanelComponent, TenantProfileQueuesComponent, QueueFormComponent, RepositorySettingsComponent, @@ -483,7 +481,6 @@ import { SolutionInstallDialogComponent } from '@home/components/solution/soluti DashboardStateDialogComponent, DashboardImageDialogComponent, EmbedDashboardDialogComponent, - DisplayWidgetTypesPanelComponent, TenantProfileQueuesComponent, QueueFormComponent, RepositorySettingsComponent, diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html deleted file mode 100644 index 8332d0a268..0000000000 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html +++ /dev/null @@ -1,29 +0,0 @@ - -
- search - - -
diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss deleted file mode 100644 index 649f082696..0000000000 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 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. - */ -.input-wrapper { - background: hsla(0, 0%, 100%, .2); - border-radius: 8px; - color: rgb(255, 255, 255, .84); - padding: 5px 8px 5px 16px; - height: 46px; - - input { - width: 100%; - height: 100%; - padding: 0; - font-size: 20px; - outline: none; - border: none; - background-color: transparent; - color: #fff; - - &::placeholder { - color: #fff; - opacity: .6; - line-height: 26px; - } - } - - &.focus { - background: #fff; - color: rgba(0,0,0,.54); - - input { - color: #000; - opacity: .60; - } - - .close { - color: #000 !important; - opacity: .54; - } - } -} - diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts deleted file mode 100644 index 8f15905141..0000000000 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -/// -/// 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, ElementRef, forwardRef, Input, ViewChild, ViewEncapsulation } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; - -@Component({ - selector: 'tb-widgets-bundle-search', - templateUrl: './widgets-bundle-search.component.html', - styleUrls: ['./widgets-bundle-search.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetsBundleSearchComponent), - multi: true - }], - encapsulation: ViewEncapsulation.None, - standalone: false -}) -export class WidgetsBundleSearchComponent implements ControlValueAccessor { - - searchText: string; - focus = false; - - @Input() placeholder: string; - - @ViewChild('searchInput') searchInput: ElementRef; - - private propagateChange = (v: any) => { }; - - constructor() { - } - - registerOnChange(fn: any): void { - this.propagateChange = fn; - } - - registerOnTouched(fn: any): void { - } - - writeValue(value: string | null): void { - this.searchText = value; - } - - updateSearchText(): void { - this.updateView(); - } - - private updateView() { - this.propagateChange(this.searchText); - } - - clear($event: Event): void { - $event.preventDefault(); - $event.stopPropagation(); - this.searchText = ''; - this.updateView(); - } - - toggleFocus() { - this.focus = !this.focus; - } -} diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts index 5e8e8c2562..88f350bc3b 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts @@ -48,3 +48,8 @@ export interface ItemTypeFilterInfo { hardwareTypes: FilterParamInfo[]; connectivities: Record; } + +export interface WidgetCategory { + name: string; + image: string; +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts index 3edb1b5e46..a4b942b16a 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts @@ -126,7 +126,8 @@ export class MpItemVersionQuery { public tbVersion?: number, public hardwareTypes?: string[], public connectivity?: string[], - public vendors?: string[] + public vendors?: string[], + public scadaFirst?: boolean ) {} public toQuery(): string { @@ -167,6 +168,9 @@ export class MpItemVersionQuery { if (this.vendors?.length) { query += this.vendors.map(v => `&vendors=${encodeURIComponent(v)}`).join(''); } + if (this.scadaFirst != null) { + query += `&scadaFirst=${this.scadaFirst}`; + } return query; } } diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 37d9d56f81..21addee9e1 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -147,7 +147,6 @@ import { QueueAutocompleteComponent } from '@shared/components/queue/queue-autoc import { ContactComponent } from '@shared/components/contact.component'; import { TimezoneSelectComponent } from '@shared/components/time/timezone-select.component'; import { FileSizePipe } from '@shared/pipe/file-size.pipe'; -import { WidgetsBundleSearchComponent } from '@shared/components/widgets-bundle-search.component'; import { SelectableColumnsPipe } from '@shared/pipe/selectable-columns.pipe'; import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component'; import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/ota-package-autocomplete.component'; @@ -408,7 +407,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) EntityGatewaySelectComponent, ContactComponent, OtaPackageAutocompleteComponent, - WidgetsBundleSearchComponent, CopyButtonComponent, TogglePasswordComponent, WidgetButtonToggleComponent, @@ -675,7 +673,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) EntityGatewaySelectComponent, ContactComponent, OtaPackageAutocompleteComponent, - WidgetsBundleSearchComponent, CopyButtonComponent, TogglePasswordComponent, WidgetButtonToggleComponent, 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 73381a3f58..942e276fe3 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,14 @@ "search-results-for": "Search results for ", "try-adjusting-search": "Try adjusting your search", "add-widget-from-iot-hub": "Add widget from IoT Hub", + "all-widgets": "All widgets", + "installed-from-iot-hub": "Installed from IoT Hub", + "include-deprecated": "Include deprecated", + "widget-category-value": "{{ category }} widgets", + "search-categories": "Search categories...", + "search-in-category": "Search widgets...", + "search-bundles": "Search bundles...", + "search-widgets": "Search widgets...", "add-item-from-iot-hub": "Add {{ type }} from IoT Hub", "add-from-iot-hub": "Add from IoT Hub", "filters": "Filters", @@ -6956,6 +6964,7 @@ "widget-bundle": "Widgets Bundle", "all-bundles": "All bundles", "select-widgets-bundle": "Select widgets bundle", + "installed": "Installed", "widgets": "Widgets", "all-widgets": "All widgets", "widget": "Widget", @@ -6986,6 +6995,11 @@ "rpc-short": "control", "alarm-short": "alarm", "static-short": "static", + "type-timeseries": "Timeseries", + "type-latest": "Latest", + "type-rpc": "Control", + "type-alarm": "Alarm", + "type-static": "Static", "select-widget-type": "Select widget type", "missing-widget-title-error": "Widget title must be specified!", "widget-saved": "Widget saved", diff --git a/ui-ngx/src/assets/widget/all-widgets.svg b/ui-ngx/src/assets/widget/all-widgets.svg new file mode 100644 index 0000000000..a518d44d45 --- /dev/null +++ b/ui-ngx/src/assets/widget/all-widgets.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui-ngx/src/assets/widget/installed-from-iot-hub.svg b/ui-ngx/src/assets/widget/installed-from-iot-hub.svg new file mode 100644 index 0000000000..8500e7cf91 --- /dev/null +++ b/ui-ngx/src/assets/widget/installed-from-iot-hub.svg @@ -0,0 +1,9 @@ + + + + + + + + +