Browse Source

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
pull/15508/head
Igor Kulikov 5 months ago
parent
commit
666e8f4b68
  1. 18
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  2. 4
      ui-ngx/src/app/modules/common/modules-map.ts
  3. 98
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html
  4. 1
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss
  5. 64
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  6. 672
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html
  7. 165
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss
  8. 490
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  9. 23
      ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html
  10. 36
      ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss
  11. 49
      ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts
  12. 3
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  13. 29
      ui-ngx/src/app/shared/components/widgets-bundle-search.component.html
  14. 55
      ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss
  15. 75
      ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts
  16. 5
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts
  17. 6
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts
  18. 3
      ui-ngx/src/app/shared/shared.module.ts
  19. 14
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  20. 7
      ui-ngx/src/assets/widget/all-widgets.svg
  21. 9
      ui-ngx/src/assets/widget/installed-from-iot-hub.svg

18
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<WidgetCategory[]> {
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<WidgetCategory[]>(url, { params: this.buildParams(config) });
}
public getVersionInfo(versionId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/versions/${versionId}`,

4
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,

98
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html

@ -400,95 +400,15 @@
(applyWidgetConfig)="saveWidget()">
</tb-edit-widget>
</tb-details-panel>
<tb-details-panel *ngIf="!isAddingWidgetClosed && !widgetEditMode" class="flex-1"
headerTitle="{{ isAddingWidget ?
(dashboardWidgetSelectComponent?.selectWidgetMode === 'iotHub' ? ('iot-hub.add-widget-from-iot-hub' | translate) :
(dashboardWidgetSelectComponent?.selectWidgetMode === 'allWidgets' ? ('dashboard.select-widget-value' | translate: { title: ('widget.all-widgets' | translate) }) :
(!dashboardWidgetSelectComponent?.widgetsBundle ?
'widget.select-widgets-bundle' : 'dashboard.select-widget-value') | translate: dashboardWidgetSelectComponent?.widgetsBundle)) : ''
}}"
headerHeightPx="64"
[isShowSearch]="true"
[isReadOnly]="true"
[isEdit]="false"
backgroundColor="#cfd8dc"
(closeDetails)="onAddWidgetClosed()"
(closeSearch)="dashboardWidgetSelectComponent.search = ''">
<div class="prefix-title-buttons" [class.!hidden]="!(isAddingWidget && (dashboardWidgetSelectComponent?.widgetsBundle || dashboardWidgetSelectComponent?.selectWidgetMode === 'allWidgets' || dashboardWidgetSelectComponent?.selectWidgetMode === 'iotHub'))" style="height: 28px; margin-right: 12px">
<button class="tb-mat-28" mat-icon-button type="button" (click)="clearSelectedWidgetBundle()">
<mat-icon>arrow_back</mat-icon>
</button>
</div>
<div class="search-pane flex flex-row" *ngIf="isAddingWidget && dashboardWidgetSelectComponent">
<tb-widgets-bundle-search class="flex-1"
[(ngModel)]="dashboardWidgetSelectComponent.search"
placeholder="{{ ((!dashboardWidgetSelectComponent.widgetsBundle && dashboardWidgetSelectComponent.selectWidgetMode === 'bundles')
? 'widgets-bundle.search' : 'widget.search') | translate }}">
</tb-widgets-bundle-search>
</div>
<div class="details-buttons flex flex-row items-center justify-start" *ngIf="isAddingWidget">
<button mat-button type="button" (click)="importWidget($event)"
*ngIf="dashboardWidgetSelectComponent?.selectWidgetMode === 'bundles' && !dashboardWidgetSelectComponent?.widgetsBundle">
<mat-icon>file_upload</mat-icon>{{ 'dashboard.import-widget' | translate }}</button>
<tb-toggle-select *ngIf="dashboardWidgetSelectComponent?.selectWidgetMode !== 'iotHub' && (dashboardWidgetSelectComponent?.selectWidgetMode === 'bundles' && !dashboardWidgetSelectComponent?.widgetsBundle)"
appearance="fill-invert"
disablePagination
selectMediaBreakpoint="xs"
[(ngModel)]="dashboardWidgetSelectComponent.selectWidgetMode">
<tb-toggle-option value="bundles">{{ 'widgets-bundle.widgets-bundles' | translate }}</tb-toggle-option>
<tb-toggle-option value="allWidgets">{{ 'widget.all-widgets' | translate }}</tb-toggle-option>
<tb-toggle-option value="iotHub">{{ 'iot-hub.iot-hub' | translate }}</tb-toggle-option>
</tb-toggle-select>
<button mat-icon-button type="button"
*ngIf="dashboardWidgetSelectComponent?.selectWidgetMode !== 'iotHub' && dashboardWidgetSelectComponent?.widgetTypes.size > 1"
(click)="editWidgetsTypesToDisplay($event)"
matTooltip="{{ 'widget.filter' | translate }}"
matTooltipPosition="above">
<mat-icon>filter_list</mat-icon>
</button>
<tb-toggle-select *ngIf="dashboardWidgetSelectComponent?.selectWidgetMode !== 'iotHub' && dashboardWidgetSelectComponent?.hasDeprecated"
appearance="fill-invert"
disablePagination
selectMediaBreakpoint="xs"
[(ngModel)]="dashboardWidgetSelectComponent.deprecatedFilter">
<tb-toggle-option value="ALL">{{ 'widget.all' | translate }}</tb-toggle-option>
<tb-toggle-option value="ACTUAL">{{ 'widget.actual' | translate }}</tb-toggle-option>
<tb-toggle-option value="DEPRECATED">{{ 'widget.deprecated' | translate }}</tb-toggle-option>
</tb-toggle-select>
<button mat-icon-button type="button"
*ngIf="dashboardWidgetSelectComponent?.selectWidgetMode === 'iotHub'"
tb-popover #iotHubFilterPopover="tbPopover"
[tbPopoverContent]="dashboardWidgetSelectComponent?.iotHubFilterPanel"
[tbPopoverContext]="{ popover: iotHubFilterPopover }"
tbPopoverTrigger="click"
(tbPopoverVisibleChange)="$event && dashboardWidgetSelectComponent?.openIotHubFilterPanel()"
[tbPopoverOverlayStyle]="{width: '380px'}"
matTooltip="{{ 'iot-hub.filter' | translate }}"
matTooltipPosition="above">
<mat-icon [matBadge]="dashboardWidgetSelectComponent?.iotHubFilterCount || null"
[matBadgeHidden]="!dashboardWidgetSelectComponent?.iotHubFilterCount"
matBadgeColor="warn"
matBadgeSize="small"
aria-hidden="false">filter_list</mat-icon>
</button>
<tb-toggle-select *ngIf="dashboardWidgetSelectComponent?.selectWidgetMode === 'iotHub'"
appearance="fill-invert"
disablePagination
selectMediaBreakpoint="xs"
[ngModel]="dashboardWidgetSelectComponent.iotHubInstalledMode"
(ngModelChange)="dashboardWidgetSelectComponent.onIotHubInstalledModeChange($event)">
<tb-toggle-option value="all">{{ 'widget.all' | translate }}</tb-toggle-option>
<tb-toggle-option value="installed">{{ 'iot-hub.installed' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-dashboard-widget-select #dashboardWidgetSelect
*ngIf="isAddingWidget"
[aliasController]="dashboardCtx.aliasController"
[filterWidgetTypes]="filterWidgetTypes"
[scadaFirst]="layouts.main.layoutCtx.gridSettings.layoutType === LayoutType.scada"
(widgetSelected)="addWidgetFromType($event)">
</tb-dashboard-widget-select>
</tb-details-panel>
<tb-dashboard-widget-select #dashboardWidgetSelect
*ngIf="!isAddingWidgetClosed && !widgetEditMode && isAddingWidget"
class="flex-1"
[aliasController]="dashboardCtx.aliasController"
[scadaFirst]="layouts.main.layoutCtx.gridSettings.layoutType === LayoutType.scada"
(widgetSelected)="addWidgetFromType($event)"
(closeClick)="onAddWidgetClosed()"
(importClick)="importWidget($event)">
</tb-dashboard-widget-select>
</mat-drawer>
</mat-drawer-container>
</section>

1
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss

@ -195,3 +195,4 @@ div.tb-dashboard-page {
}
}

64
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();

672
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html

@ -16,19 +16,227 @@
-->
<div class="tb-dashboard-widget-select">
<ng-container *ngIf="selectWidgetMode === 'iotHub'; else standardModes">
<ng-container *ngTemplateOutlet="iotHubWidgets"></ng-container>
</ng-container>
<ng-template #standardModes>
<ng-container *ngIf="selectWidgetMode === 'bundles'; else allWidgets">
<ng-container *ngIf="widgetsBundle; else bundles">
<mat-toolbar color="primary" class="tb-widget-select-header flex flex-col">
<div class="tb-widget-select-title-row flex flex-row items-center">
@if (showTopLevelWidgetModeToggle()) {
<tb-toggle-select appearance="fill-invert"
[disablePagination]="true"
selectMediaBreakpoint="xs"
[(ngModel)]="selectWidgetMode">
<tb-toggle-option value="installed">{{ 'widget.installed' | translate }}</tb-toggle-option>
<tb-toggle-option value="iotHub">{{ 'iot-hub.iot-hub' | translate }}</tb-toggle-option>
</tb-toggle-select>
} @else if (showBackButton()) {
<button mat-icon-button type="button" (click)="onBackClick()">
<mat-icon>arrow_back</mat-icon>
</button>
<span class="tb-widget-select-title">{{ getHeaderTitle() }}</span>
}
<span class="flex-1"></span>
@if (selectWidgetMode === 'installed') {
<button mat-icon-button type="button" (click)="onImport($event)"
matTooltip="{{ 'dashboard.import-widget' | translate }}"
matTooltipPosition="above">
<mat-icon>file_upload</mat-icon>
</button>
}
<button mat-icon-button type="button" (click)="onClose()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="tb-widget-select-search-row flex flex-row items-center">
<div class="tb-widget-select-search flex flex-1 items-center gap-4"
[class.focus]="searchFocused">
<mat-icon>search</mat-icon>
<input type="text"
[(ngModel)]="search"
(focus)="searchFocused = true"
(blur)="searchFocused = false"
[placeholder]="getSearchPlaceholder()">
@if (search) {
<button type="button" mat-icon-button (click)="clearSearch($event)">
<mat-icon class="close">close</mat-icon>
</button>
}
</div>
@if (isFilterVisible()) {
<button mat-icon-button type="button"
tb-popover #widgetSelectFilterPopover="tbPopover"
[tbPopoverContent]="iotHubFilterPanel"
[tbPopoverContext]="{ popover: widgetSelectFilterPopover }"
tbPopoverTrigger="click"
[tbPopoverShowCloseButton]="false"
tbPopoverPlacement="bottomRight"
[tbPopoverOverlayStyle]="{width: '380px'}"
matTooltip="{{ 'iot-hub.filter' | translate }}"
matTooltipPosition="above">
<mat-icon [matBadge]="totalFilterCount || null"
[matBadgeHidden]="!totalFilterCount"
matBadgeColor="warn"
matBadgeSize="small"
aria-hidden="false">filter_list</mat-icon>
</button>
}
@if (selectWidgetMode === 'iotHub' && iotHubSubMode !== 'default') {
<button mat-button type="button" class="tb-widget-select-sort" [matMenuTriggerFor]="sortMenu">
{{ iotHubSortOptions[iotHubSelectedSortIndex].label | translate }}
<mat-icon iconPositionEnd>arrow_drop_down</mat-icon>
</button>
<mat-menu #sortMenu="matMenu">
@for (option of iotHubSortOptions; track option.value; let i = $index) {
<button mat-menu-item (click)="onIotHubSortChange(i)">{{ option.label | translate }}</button>
}
</mat-menu>
}
</div>
</mat-toolbar>
<div class="tb-widget-select-body">
@switch (selectWidgetMode) {
@case ('installed') {
@if (widgetsBundle) {
<ng-container *ngTemplateOutlet="widgets; context:{ fetchFunction: widgetsFetchFunction, filter: widgetsFilter }"></ng-container>
</ng-container>
</ng-container>
<ng-template #allWidgets>
<ng-container *ngTemplateOutlet="widgets; context:{ fetchFunction: allWidgetsFetchFunction, filter: allWidgetsFilter }"></ng-container>
</ng-template>
</ng-template>
} @else if (installedSubMode === 'allWidgets') {
<ng-container *ngTemplateOutlet="widgets; context:{ fetchFunction: allWidgetsFetchFunction, filter: allWidgetsFilter }"></ng-container>
} @else {
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="installedDefaultFetchFunction"
[filter]="widgetsBundleFilter"
[itemCard]="installedDefaultCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingWidgetBundles"
[noData]="noWidgetBundles">
</tb-scroll-grid>
<ng-template #installedDefaultCard let-item="item">
@switch (item.__logical) {
@case (LOGICAL_ALL_WIDGETS) {
<mat-card class="tb-widget-preview-card tb-widget-logical-card flex flex-col gap-2"
appearance="raised"
(click)="onAllWidgetsCardClicked($event)">
<div class="title-container">
<div class="widget-title">{{ 'widget.all-widgets' | translate }}</div>
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
<img class="preview" src="/assets/widget/all-widgets.svg" alt="{{ 'widget.all-widgets' | translate }}">
</div>
</mat-card>
}
@default {
<mat-card class="tb-widget-preview-card flex flex-col gap-2" appearance="raised" (click)="selectBundle($event, item)">
<div class="title-container">
<div title="{{item.name}}" class="widget-title">
{{item.title}}
</div>
<div class="title-items">
@if (isSystem(item)) {
<div class="widget-type">sys</div>
}
<div tb-popover [tbPopoverContent]="item.description" [tbPopoverOverlayStyle]="{maxWidth: '300px'}" [tbPopoverShowCloseButton]="false" class="info-banner tb-primary-fill"><span>i</span></div>
</div>
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
<img class="preview" [src]="item.image | image: {emptyUrl: '/assets/widget-preview-empty.svg'} | async" alt="{{ item.title }}">
</div>
</mat-card>
}
}
</ng-template>
<ng-template #loadingWidgetBundles>
<div class="tb-absolute-fill flex flex-col items-center justify-center">
<span class="mat-headline-5" style="padding-bottom: 20px;">
{{ 'widgets-bundle.loading-widgets-bundles' | translate }}
</span>
<mat-spinner strokeWidth="5"></mat-spinner>
</div>
</ng-template>
<ng-template #noWidgetBundles>
<span translate
class="mat-headline-5 tb-absolute-fill flex items-center justify-center">widgets-bundle.no-widgets-bundles-text</span>
</ng-template>
}
}
@case ('iotHub') {
@switch (iotHubSubMode) {
@case ('default') {
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="iotHubDefaultFetchFunction"
[filter]="widgetCategoriesFilter"
[itemCard]="iotHubDefaultCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingCategories"
[noData]="noCategories">
</tb-scroll-grid>
<ng-template #iotHubDefaultCard let-item="item">
@switch (item.__logical) {
@case (LOGICAL_ALL_WIDGETS) {
<mat-card class="tb-widget-preview-card tb-widget-logical-card flex flex-col gap-2"
appearance="raised"
(click)="onAllWidgetsCardClicked($event)">
<div class="title-container">
<div class="widget-title">{{ 'widget.all-widgets' | translate }}</div>
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
<img class="preview" src="/assets/widget/all-widgets.svg" alt="{{ 'widget.all-widgets' | translate }}">
</div>
</mat-card>
}
@case (LOGICAL_INSTALLED_FROM_IOT_HUB) {
<mat-card class="tb-widget-preview-card tb-widget-logical-card flex flex-col gap-2"
appearance="raised"
(click)="onInstalledFromIotHubCardClicked($event)">
<div class="title-container">
<div class="widget-title">{{ 'iot-hub.installed-from-iot-hub' | translate }}</div>
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
<img class="preview" src="/assets/widget/installed-from-iot-hub.svg" alt="{{ 'iot-hub.installed-from-iot-hub' | translate }}">
</div>
</mat-card>
}
@default {
<mat-card class="tb-widget-preview-card tb-widget-category-card flex flex-col gap-2"
appearance="raised" (click)="onCategoryCardClicked($event, item)">
<div class="title-container">
<div title="{{ item.name }}" class="widget-title">{{ item.name }}</div>
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
@if (getCategoryImageUrl(item); as imgUrl) {
<img class="preview" [src]="imgUrl" alt="{{ item.name }}">
} @else {
<img class="preview" src="/assets/widget-preview-empty.svg" alt="{{ item.name }}">
}
</div>
</mat-card>
}
}
</ng-template>
<ng-template #loadingCategories>
<div class="tb-absolute-fill flex flex-col items-center justify-center">
<mat-spinner color="accent" strokeWidth="5"></mat-spinner>
</div>
</ng-template>
<ng-template #noCategories>
<span class="mat-headline-5 tb-absolute-fill flex items-center justify-center">{{ 'iot-hub.no-items-found' | translate }}</span>
</ng-template>
}
@case ('installed') {
<ng-container *ngTemplateOutlet="iotHubInstalledGrid"></ng-container>
}
@default {
<ng-container *ngTemplateOutlet="iotHubAllGrid"></ng-container>
}
}
}
}
</div>
<ng-template #widgets let-fetchFunction="fetchFunction" let-filter="filter">
<tb-scroll-grid
[columns]="columns"
@ -49,9 +257,11 @@
<div class="title-items-container">
<div class="title-items">
<div class="widget-type">{{ 'widget.' + item.widgetType + '-short' | translate }}</div>
<div tb-popover [tbPopoverContent]="item.description" [tbPopoverOverlayStyle]="{maxWidth: '300px'}" tbPopoverShowCloseButton="false" class="info-banner tb-primary-fill"><span>i</span></div>
<div tb-popover [tbPopoverContent]="item.description" [tbPopoverOverlayStyle]="{maxWidth: '300px'}" [tbPopoverShowCloseButton]="false" class="info-banner tb-primary-fill"><span>i</span></div>
</div>
<div *ngIf="item.deprecated" class="widget-deprecated" translate>widget.deprecated</div>
@if (item.deprecated) {
<div class="widget-deprecated" translate>widget.deprecated</div>
}
</div>
</div>
<div class="preview-container">
@ -69,119 +279,62 @@
</div>
</ng-template>
<ng-template #noWidgets>
<span class="mat-headline-5 tb-absolute-fill flex items-center justify-center">{{(selectWidgetMode === 'bundles' ? 'widgets-bundle.empty' : 'widget.no-widgets-text') | translate}}</span>
<span class="mat-headline-5 tb-absolute-fill flex items-center justify-center">{{ 'widget.no-widgets-text' | translate }}</span>
</ng-template>
</ng-template>
<ng-template #bundles>
<ng-template #widgetLoadingCard>
<mat-card class="tb-widget-preview-card loading-cell flex flex-col gap-2" appearance="raised">
<div class="title-container">
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
</div>
</mat-card>
</ng-template>
<ng-template #iotHubAllGrid>
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="widgetBundlesFetchFunction"
[filter]="widgetsBundleFilter"
[itemCard]="widgetsBundleCard"
[fetchFunction]="iotHubWidgetsFetchFunction"
[filter]="iotHubWidgetsFilter"
[itemCard]="iotHubWidgetCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingWidgetBundles"
[noData]="noWidgetBundles">
[dataLoading]="loadingIotHubWidgets"
[noData]="noIotHubWidgets">
</tb-scroll-grid>
<ng-template #widgetsBundleCard let-item="item">
<mat-card class="tb-widget-preview-card flex flex-col gap-2" appearance="raised" (click)="selectBundle($event, item)">
<div class="title-container">
<div title="{{item.name}}" class="widget-title">
{{item.title}}
</div>
<div class="title-items">
<div *ngIf="isSystem(item)" class="widget-type">sys</div>
<div tb-popover [tbPopoverContent]="item.description" [tbPopoverOverlayStyle]="{maxWidth: '300px'}" tbPopoverShowCloseButton="false" class="info-banner tb-primary-fill"><span>i</span></div>
</div>
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
<img class="preview" [src]="item.image | image: {emptyUrl: '/assets/widget-preview-empty.svg'} | async" alt="{{ item.title }}">
</div>
</mat-card>
<ng-template #iotHubWidgetCard let-item="item">
<ng-container *ngTemplateOutlet="iotHubWidgetCardTpl; context: { $implicit: item, showInstalledBadge: true }"></ng-container>
</ng-template>
<ng-template #loadingWidgetBundles>
<ng-template #loadingIotHubWidgets>
<div class="tb-absolute-fill flex flex-col items-center justify-center">
<span class="mat-headline-5" style="padding-bottom: 20px;">
{{ 'widgets-bundle.loading-widgets-bundles' | translate }}
{{ 'widget.loading-widgets' | translate }}
</span>
<mat-spinner strokeWidth="5"></mat-spinner>
<mat-spinner color="accent" strokeWidth="5"></mat-spinner>
</div>
</ng-template>
<ng-template #noWidgetBundles>
<span translate
class="mat-headline-5 tb-absolute-fill flex items-center justify-center">widgets-bundle.no-widgets-bundles-text</span>
<ng-template #noIotHubWidgets>
<span class="mat-headline-5 tb-absolute-fill flex items-center justify-center">{{ 'iot-hub.no-items-found' | translate }}</span>
</ng-template>
</ng-template>
<ng-template #widgetLoadingCard>
<mat-card class="tb-widget-preview-card loading-cell flex flex-col gap-2" appearance="raised">
<div class="title-container">
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
</div>
</mat-card>
</ng-template>
<ng-template #iotHubWidgets>
<ng-container *ngIf="iotHubInstalledMode === 'all'; else iotHubInstalledGrid">
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="iotHubWidgetsFetchFunction"
[filter]="iotHubWidgetsFilter"
[itemCard]="iotHubWidgetCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingIotHubWidgets"
[noData]="noIotHubWidgets">
</tb-scroll-grid>
</ng-container>
<ng-template #iotHubInstalledGrid>
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="iotHubInstalledWidgetsFetchFunction"
[filter]="iotHubInstalledWidgetsFilter"
[itemCard]="iotHubWidgetCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingIotHubWidgets"
[noData]="noIotHubWidgets">
</tb-scroll-grid>
</ng-template>
<ng-template #iotHubWidgetCard let-item="item">
<mat-card class="tb-widget-preview-card tb-iot-hub-widget-card flex flex-col gap-2" appearance="raised" (click)="onIotHubWidgetClicked($event, item)">
<div class="title-container">
<div title="{{item.name}}" class="widget-title">
{{item.name}}
</div>
<div class="title-items">
<div *ngIf="item.dataDescriptor?.widgetType" class="widget-type">{{ 'widget.' + item.dataDescriptor.widgetType + '-short' | translate }}</div>
<div tb-popover [tbPopoverContent]="item.description" [tbPopoverOverlayStyle]="{maxWidth: '300px'}" tbPopoverShowCloseButton="false" class="info-banner tb-primary-fill"><span>i</span></div>
</div>
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
<img *ngIf="getIotHubItemImage(item) as imgUrl" class="preview" [src]="imgUrl" alt="{{ item.name }}">
<img *ngIf="!getIotHubItemImage(item)" class="preview" src="/assets/widget-preview-empty.svg" alt="{{ item.name }}">
</div>
<div class="tb-iot-hub-widget-footer">
<span class="tb-iot-hub-widget-author">
<mat-icon>person</mat-icon>
{{ item.creatorDisplayName }}
</span>
<span *ngIf="iotHubInstalledMode !== 'installed' && isIotHubWidgetInstalled(item)" class="tb-iot-hub-widget-installed-badge">
<mat-icon>check</mat-icon>
{{ 'iot-hub.installed' | translate }}
</span>
<span class="tb-iot-hub-widget-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
</div>
</mat-card>
<ng-template #iotHubInstalledGrid>
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="iotHubInstalledWidgetsFetchFunction"
[filter]="iotHubInstalledWidgetsFilter"
[itemCard]="iotHubInstalledWidgetCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingIotHubInstalledWidgets"
[noData]="noIotHubInstalledWidgets">
</tb-scroll-grid>
<ng-template #iotHubInstalledWidgetCard let-item="item">
<ng-container *ngTemplateOutlet="iotHubWidgetCardTpl; context: { $implicit: item, showInstalledBadge: false }"></ng-container>
</ng-template>
<ng-template #loadingIotHubWidgets>
<ng-template #loadingIotHubInstalledWidgets>
<div class="tb-absolute-fill flex flex-col items-center justify-center">
<span class="mat-headline-5" style="padding-bottom: 20px;">
{{ 'widget.loading-widgets' | translate }}
@ -189,139 +342,222 @@
<mat-spinner color="accent" strokeWidth="5"></mat-spinner>
</div>
</ng-template>
<ng-template #noIotHubWidgets>
<ng-template #noIotHubInstalledWidgets>
<span class="mat-headline-5 tb-absolute-fill flex items-center justify-center">{{ 'iot-hub.no-items-found' | translate }}</span>
</ng-template>
</ng-template>
<ng-template #iotHubWidgetCardTpl let-item let-showInstalledBadge="showInstalledBadge">
<mat-card class="tb-widget-preview-card tb-iot-hub-widget-card flex flex-col gap-2" appearance="raised" (click)="onIotHubWidgetClicked($event, item)">
<div class="title-container">
<div title="{{item.name}}" class="widget-title">
{{item.name}}
</div>
<div class="title-items">
@if (item.dataDescriptor?.widgetType) {
<div class="widget-type">{{ 'widget.' + item.dataDescriptor.widgetType + '-short' | translate }}</div>
}
<div tb-popover [tbPopoverContent]="item.description" [tbPopoverOverlayStyle]="{maxWidth: '300px'}" [tbPopoverShowCloseButton]="false" class="info-banner tb-primary-fill"><span>i</span></div>
</div>
</div>
<div class="preview-container">
<div class="preview-spacer"></div>
@if (getIotHubItemImage(item); as imgUrl) {
<img class="preview" [src]="imgUrl" alt="{{ item.name }}">
} @else {
<img class="preview" src="/assets/widget-preview-empty.svg" alt="{{ item.name }}">
}
</div>
<div class="tb-iot-hub-widget-footer">
<span class="tb-iot-hub-widget-author">
@if (item.creatorVerified) {
<mat-icon class="tb-iot-hub-verified-icon">verified</mat-icon>
} @else {
<mat-icon>person</mat-icon>
}
{{ item.creatorDisplayName }}
</span>
@if (showInstalledBadge && isIotHubWidgetInstalled(item)) {
<span class="tb-iot-hub-widget-installed-badge">
<mat-icon>check</mat-icon>
{{ 'iot-hub.installed' | translate }}
</span>
}
<span class="tb-iot-hub-widget-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
</div>
</mat-card>
</ng-template>
<!-- IoT Hub Filter Panel -->
<ng-template #iotHubFilterPanel let-popover="popover">
<div class="tb-iot-hub-filter-panel">
<div class="tb-iot-hub-filter-header">
<span class="tb-iot-hub-filter-title">{{ 'iot-hub.filters' | translate }}</span>
<span class="flex-1"></span>
@if (hasActiveFilters()) {
<button mat-stroked-button color="primary" class="tb-wsp-clear-all" (click)="clearAllFilters()">
{{ 'iot-hub.clear-all' | translate }}
</button>
}
<button mat-icon-button (click)="popover?.hide()" class="tb-wsp-close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
<div class="tb-iot-hub-filter-sections" [class.filter-items-hovered]="iotHubFilterItemsHovered">
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.type' | translate }}
@if (iotHubPendingWidgetTypes.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubPendingWidgetTypes.size }}</span>
}
</mat-panel-title>
</mat-expansion-panel-header>
@if (iotHubWidgetTypeOptions.length >= 10) {
<div class="tb-iot-hub-filter-search">
<mat-icon>search</mat-icon>
<input [placeholder]="'iot-hub.search' | translate"
[ngModel]="iotHubFilterSearch['widgetType']"
(ngModelChange)="iotHubFilterSearch['widgetType'] = $event">
@if (iotHubFilterSearch['widgetType']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['widgetType'] = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (opt of getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType'); track opt.key) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubPendingWidgetTypes.has(opt.key)"
(change)="toggleIotHubWidgetType(opt.key)">
{{ getIotHubWidgetTypeLabel(opt.key) }}
</mat-checkbox>
@if (selectWidgetMode === 'installed') {
@if (widgetTypes.size > 1) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.type' | translate }}
@if (filterWidgetTypes?.length) {
<span class="tb-iot-hub-filter-badge">{{ filterWidgetTypes.length }}</span>
}
</mat-panel-title>
</mat-expansion-panel-header>
<div class="tb-iot-hub-filter-items">
@for (type of widgetTypesList; track type) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="isWidgetTypeChecked(type)"
(change)="toggleWidgetTypeFilter(type)">
{{ getWidgetTypeLabel(type) }}
</mat-checkbox>
</div>
}
</div>
</mat-expansion-panel>
}
@if (hasDeprecated) {
@if (widgetTypes.size > 1) {
<mat-divider></mat-divider>
}
</div>
</mat-expansion-panel>
<mat-divider></mat-divider>
<mat-expansion-panel [expanded]="false">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.category' | translate }}
@if (iotHubPendingCategories.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubPendingCategories.size }}</span>
}
</mat-panel-title>
</mat-expansion-panel-header>
@if (iotHubCategoryOptions.length >= 10) {
<div class="tb-iot-hub-filter-search">
<mat-icon>search</mat-icon>
<input [placeholder]="'iot-hub.search' | translate"
[ngModel]="iotHubFilterSearch['category']"
(ngModelChange)="iotHubFilterSearch['category'] = $event">
@if (iotHubFilterSearch['category']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['category'] = ''">
<mat-icon>close</mat-icon>
</button>
}
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="includeDeprecated"
(change)="onIncludeDeprecatedChange($event.checked)">
{{ 'iot-hub.include-deprecated' | translate }}
</mat-checkbox>
</div>
}
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubCategoryOptions, 'category').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubCategoryOptions, 'category').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (opt of getFilteredIotHubItems(iotHubCategoryOptions, 'category'); track opt.key) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubPendingCategories.has(opt.key)"
(change)="toggleIotHubCategory(opt.key)">
{{ opt.key }}
</mat-checkbox>
} @else {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.type' | translate }}
@if (iotHubAppliedWidgetTypes.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubAppliedWidgetTypes.size }}</span>
}
</mat-panel-title>
</mat-expansion-panel-header>
@if (iotHubWidgetTypeOptions.length >= 10) {
<div class="tb-iot-hub-filter-search">
<mat-icon>search</mat-icon>
<input [placeholder]="'iot-hub.search' | translate"
[ngModel]="iotHubFilterSearch['widgetType']"
(ngModelChange)="iotHubFilterSearch['widgetType'] = $event">
@if (iotHubFilterSearch['widgetType']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['widgetType'] = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
</div>
</mat-expansion-panel>
<mat-divider></mat-divider>
<mat-expansion-panel [expanded]="false">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.use-case' | translate }}
@if (iotHubPendingUseCases.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubPendingUseCases.size }}</span>
}
</mat-panel-title>
</mat-expansion-panel-header>
@if (iotHubUseCaseOptions.length >= 10) {
<div class="tb-iot-hub-filter-search">
<mat-icon>search</mat-icon>
<input [placeholder]="'iot-hub.search' | translate"
[ngModel]="iotHubFilterSearch['useCase']"
(ngModelChange)="iotHubFilterSearch['useCase'] = $event">
@if (iotHubFilterSearch['useCase']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['useCase'] = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubUseCaseOptions, 'useCase').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubUseCaseOptions, 'useCase').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (group of getGroupedIotHubFilterItems(iotHubUseCaseOptions, 'useCase'); track group.label) {
@if (group.label) {
<div class="tb-iot-hub-filter-group-label">{{ group.label | translate }}</div>
}
@for (opt of group.items; track opt.key) {
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (opt of getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType'); track opt.key) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubPendingUseCases.has(opt.key)"
(change)="toggleIotHubUseCase(opt.key)">
{{ opt.key }}
<mat-checkbox [checked]="iotHubAppliedWidgetTypes.has(opt.key)"
(change)="toggleIotHubWidgetType(opt.key)">
{{ getIotHubWidgetTypeLabel(opt.key) }}
</mat-checkbox>
</div>
}
</div>
</mat-expansion-panel>
@if (!hideCategoriesFilterSection) {
<mat-divider></mat-divider>
<mat-expansion-panel [expanded]="false">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.category' | translate }}
@if (iotHubAppliedCategories.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubAppliedCategories.size }}</span>
}
</mat-panel-title>
</mat-expansion-panel-header>
@if (iotHubCategoryOptions.length >= 10) {
<div class="tb-iot-hub-filter-search">
<mat-icon>search</mat-icon>
<input [placeholder]="'iot-hub.search' | translate"
[ngModel]="iotHubFilterSearch['category']"
(ngModelChange)="iotHubFilterSearch['category'] = $event">
@if (iotHubFilterSearch['category']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['category'] = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubCategoryOptions, 'category').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubCategoryOptions, 'category').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (opt of getFilteredIotHubItems(iotHubCategoryOptions, 'category'); track opt.key) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubAppliedCategories.has(opt.key)"
(change)="toggleIotHubCategory(opt.key)">
{{ opt.key }}
</mat-checkbox>
</div>
}
</div>
</mat-expansion-panel>
}
<mat-divider></mat-divider>
<mat-expansion-panel [expanded]="false">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.use-case' | translate }}
@if (iotHubAppliedUseCases.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubAppliedUseCases.size }}</span>
}
</mat-panel-title>
</mat-expansion-panel-header>
@if (iotHubUseCaseOptions.length >= 10) {
<div class="tb-iot-hub-filter-search">
<mat-icon>search</mat-icon>
<input [placeholder]="'iot-hub.search' | translate"
[ngModel]="iotHubFilterSearch['useCase']"
(ngModelChange)="iotHubFilterSearch['useCase'] = $event">
@if (iotHubFilterSearch['useCase']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['useCase'] = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
</div>
</mat-expansion-panel>
</div>
<div class="tb-iot-hub-filter-actions">
@if (iotHubPendingWidgetTypes.size || iotHubPendingCategories.size || iotHubPendingUseCases.size) {
<button mat-stroked-button (click)="clearIotHubFilters()">{{ 'iot-hub.clear-all' | translate }}</button>
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubUseCaseOptions, 'useCase').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubUseCaseOptions, 'useCase').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (group of getGroupedIotHubFilterItems(iotHubUseCaseOptions, 'useCase'); track group.label) {
@if (group.label) {
<div class="tb-iot-hub-filter-group-label">{{ group.label | translate }}</div>
}
@for (opt of group.items; track opt.key) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubAppliedUseCases.has(opt.key)"
(change)="toggleIotHubUseCase(opt.key)">
{{ opt.key }}
</mat-checkbox>
</div>
}
}
</div>
</mat-expansion-panel>
}
<span class="flex-1"></span>
<button mat-button (click)="popover?.hide()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" [disabled]="!iotHubFilterDirty" (click)="applyIotHubFilters(); popover?.hide()">{{ 'action.apply' | translate }}</button>
</div>
</div>
</ng-template>

165
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 {

490
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<string>('');
private search$ = this.searchSubject.asObservable().pipe(
@ -72,12 +87,25 @@ export class DashboardWidgetSelectComponent implements OnInit {
private filterWidgetTypes$ = new BehaviorSubject<Array<widgetType>>(null);
private deprecatedFilter$ = new BehaviorSubject<DeprecatedFilter>(DeprecatedFilter.ACTUAL);
private selectWidgetMode$ = new BehaviorSubject<selectWidgetMode>('bundles');
private selectWidgetMode$ = new BehaviorSubject<selectWidgetMode>('installed');
private installedSubMode$ = new BehaviorSubject<installedSubMode>('default');
private iotHubSubMode$ = new BehaviorSubject<iotHubSubMode>('default');
private widgetsBundle$ = new BehaviorSubject<WidgetsBundle>(null);
widgetTypes = new Set<widgetType>();
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<widgetType>) {
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<widgetType>(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<WidgetInfo> = new EventEmitter<WidgetInfo>();
@Output()
closeClick: EventEmitter<void> = new EventEmitter<void>();
@Output()
importClick: EventEmitter<Event> = new EventEmitter<Event>();
columns: ScrollGridColumns = {
columns: 2,
breakpoints: {
@ -184,36 +247,37 @@ export class DashboardWidgetSelectComponent implements OnInit {
};
widgetBundlesFetchFunction: GridEntitiesFetchFunction<WidgetsBundle, string>;
installedDefaultFetchFunction: GridEntitiesFetchFunction<any, string>;
allWidgetsFetchFunction: GridEntitiesFetchFunction<WidgetTypeInfo, WidgetsFilter>;
widgetsFetchFunction: GridEntitiesFetchFunction<WidgetTypeInfo, BundleWidgetsFilter>;
iotHubWidgetsFetchFunction: GridEntitiesFetchFunction<MpItemVersionView, string>;
iotHubInstalledWidgetsFetchFunction: GridEntitiesFetchFunction<MpItemVersionView, string>;
iotHubDefaultFetchFunction: GridEntitiesFetchFunction<any, string>;
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<MpItemVersionView, string>;
private installedWidgets: IotHubInstalledItem[] = null;
private installedWidgetVersions: MpItemVersionView[] = null;
iotHubInstalledWidgetsFilter = '';
widgetCategoriesFilter = '';
// IoT Hub filter model — applied state (used by fetch functions)
private appliedWidgetTypes = new Set<string>();
private appliedCategories = new Set<string>();
private appliedUseCases = new Set<string>();
private installedWidgets: IotHubInstalledItem[] = null;
private installedWidgetVersions: MpItemVersionView[] = null;
// IoT Hub filter model — pending state (used by filter panel UI)
iotHubPendingWidgetTypes = new Set<string>();
iotHubPendingCategories = new Set<string>();
iotHubPendingUseCases = new Set<string>();
iotHubFilterDirty = false;
// IoT Hub filter model — applied state (used by fetch functions + panel UI)
iotHubAppliedWidgetTypes = new Set<string>();
iotHubAppliedCategories = new Set<string>();
iotHubAppliedUseCases = new Set<string>();
iotHubWidgetTypeOptions: FilterParamInfo[] = [];
iotHubCategoryOptions: FilterParamInfo[] = [];
iotHubUseCaseOptions: FilterParamInfo[] = [];
iotHubFilterCount = 0;
iotHubFilterSearch: Record<string, string> = {};
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<widgetType>(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<string>, b: Set<string>): 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),

23
ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html

@ -1,23 +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.
-->
<div class="mat-content mat-padding flex flex-col">
<mat-checkbox style="padding-bottom: 8px;" [(ngModel)]="type.display" *ngFor="let type of types"
(ngModelChange)="update()">
{{ 'widget.' + type.type | translate }}
</mat-checkbox>
</div>

36
ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss

@ -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;
}
}

49
ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts

@ -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<any>('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);
}
}

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

29
ui-ngx/src/app/shared/components/widgets-bundle-search.component.html

@ -1,29 +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.
-->
<div class="input-wrapper flex items-center justify-start gap-4" [class.focus]="focus">
<mat-icon>search</mat-icon>
<input type="text"
[(ngModel)]="searchText"
(ngModelChange)="updateSearchText()"
(focus)="toggleFocus()"
(blur)="toggleFocus()"
[placeholder]=placeholder>
<button type="button" *ngIf="searchText" mat-icon-button (click)="clear($event)">
<mat-icon class="close">close</mat-icon>
</button>
</div>

55
ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss

@ -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;
}
}
}

75
ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts

@ -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<HTMLInputElement>;
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;
}
}

5
ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts

@ -48,3 +48,8 @@ export interface ItemTypeFilterInfo {
hardwareTypes: FilterParamInfo[];
connectivities: Record<string, FilterParamInfo[]>;
}
export interface WidgetCategory {
name: string;
image: string;
}

6
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;
}
}

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

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

7
ui-ngx/src/assets/widget/all-widgets.svg

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 96" fill="none">
<rect width="120" height="96" rx="4" fill="#F5F7FA"/>
<rect x="12" y="12" width="44" height="32" rx="3" fill="#E3F2FD" stroke="#90CAF9"/>
<rect x="64" y="12" width="44" height="32" rx="3" fill="#E8F5E9" stroke="#A5D6A7"/>
<rect x="12" y="52" width="44" height="32" rx="3" fill="#FFF3E0" stroke="#FFCC80"/>
<rect x="64" y="52" width="44" height="32" rx="3" fill="#F3E5F5" stroke="#CE93D8"/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

9
ui-ngx/src/assets/widget/installed-from-iot-hub.svg

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 96" fill="none">
<rect width="120" height="96" rx="4" fill="#E8F5E9"/>
<rect x="18" y="18" width="40" height="28" rx="3" fill="#FFFFFF" stroke="#A5D6A7"/>
<rect x="62" y="18" width="40" height="28" rx="3" fill="#FFFFFF" stroke="#A5D6A7"/>
<rect x="18" y="50" width="40" height="28" rx="3" fill="#FFFFFF" stroke="#A5D6A7"/>
<rect x="62" y="50" width="40" height="28" rx="3" fill="#FFFFFF" stroke="#A5D6A7"/>
<circle cx="98" cy="74" r="13" fill="#198038"/>
<path d="M98 67v12M93 74l5 5 5-5" stroke="#FFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 647 B

Loading…
Cancel
Save