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 { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.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 { 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 { InterceptorHttpParams } from '@core/interceptors/interceptor-http-params';
import { InterceptorConfig } from '@core/interceptors/interceptor-config'; import { InterceptorConfig } from '@core/interceptors/interceptor-config';
import { AppState } from '@core/core.state'; 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> { public getVersionInfo(versionId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>( return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/versions/${versionId}`, `${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 EntityGatewaySelectComponent from '@shared/components/entity/entity-gateway-select.component';
import * as ContactComponent from '@shared/components/contact.component'; import * as ContactComponent from '@shared/components/contact.component';
import * as OtaPackageAutocompleteComponent from '@shared/components/ota-package/ota-package-autocomplete.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 CopyButtonComponent from '@shared/components/button/copy-button.component';
import * as TogglePasswordComponent from '@shared/components/button/toggle-password.component'; import * as TogglePasswordComponent from '@shared/components/button/toggle-password.component';
import * as ProtobufContentComponent from '@shared/components/protobuf-content.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 EmbedDashboardDialogComponent from '@home/components/widget/dialog/embed-dashboard-dialog.component';
import * as EdgeDownlinkTableComponent from '@home/components/edge/edge-downlink-table.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 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 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 DashboardImageDialogComponent from '@home/components/dashboard-page/dashboard-image-dialog.component';
import * as WidgetContainerComponent from '@home/components/widget/widget-container.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/entity/entity-gateway-select.component': EntityGatewaySelectComponent,
'@shared/components/contact.component': ContactComponent, '@shared/components/contact.component': ContactComponent,
'@shared/components/ota-package/ota-package-autocomplete.component': OtaPackageAutocompleteComponent, '@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/copy-button.component': CopyButtonComponent,
'@shared/components/button/toggle-password.component': TogglePasswordComponent, '@shared/components/button/toggle-password.component': TogglePasswordComponent,
'@shared/components/protobuf-content.component': ProtobufContentComponent, '@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/widget/dialog/embed-dashboard-dialog.component': EmbedDashboardDialogComponent,
'@home/components/edge/edge-downlink-table.component': EdgeDownlinkTableComponent, '@home/components/edge/edge-downlink-table.component': EdgeDownlinkTableComponent,
'@home/components/edge/edge-downlink-table-header.component': EdgeDownlinkTableHeaderComponent, '@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/profile/alarm/alarm-duration-predicate-value.component': AlarmDurationPredicateValueComponent,
'@home/components/dashboard-page/dashboard-image-dialog.component': DashboardImageDialogComponent, '@home/components/dashboard-page/dashboard-image-dialog.component': DashboardImageDialogComponent,
'@home/components/widget/widget-container.component': WidgetContainerComponent, '@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()"> (applyWidgetConfig)="saveWidget()">
</tb-edit-widget> </tb-edit-widget>
</tb-details-panel> </tb-details-panel>
<tb-details-panel *ngIf="!isAddingWidgetClosed && !widgetEditMode" class="flex-1" <tb-dashboard-widget-select #dashboardWidgetSelect
headerTitle="{{ isAddingWidget ? *ngIf="!isAddingWidgetClosed && !widgetEditMode && isAddingWidget"
(dashboardWidgetSelectComponent?.selectWidgetMode === 'iotHub' ? ('iot-hub.add-widget-from-iot-hub' | translate) : class="flex-1"
(dashboardWidgetSelectComponent?.selectWidgetMode === 'allWidgets' ? ('dashboard.select-widget-value' | translate: { title: ('widget.all-widgets' | translate) }) : [aliasController]="dashboardCtx.aliasController"
(!dashboardWidgetSelectComponent?.widgetsBundle ? [scadaFirst]="layouts.main.layoutCtx.gridSettings.layoutType === LayoutType.scada"
'widget.select-widgets-bundle' : 'dashboard.select-widget-value') | translate: dashboardWidgetSelectComponent?.widgetsBundle)) : '' (widgetSelected)="addWidgetFromType($event)"
}}" (closeClick)="onAddWidgetClosed()"
headerHeightPx="64" (importClick)="importWidget($event)">
[isShowSearch]="true" </tb-dashboard-widget-select>
[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>
</mat-drawer> </mat-drawer>
</mat-drawer-container> </mat-drawer-container>
</section> </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, OnInit,
Optional, Optional,
Renderer2, Renderer2,
StaticProvider,
ViewChild, ViewChild,
ViewContainerRef, ViewContainerRef,
ViewEncapsulation, ViewEncapsulation,
@ -79,7 +78,6 @@ import {
WidgetConfig, WidgetConfig,
WidgetInfo, WidgetInfo,
WidgetPosition, WidgetPosition,
widgetType,
widgetTypesData widgetTypesData
} from '@shared/models/widget.models'; } from '@shared/models/widget.models';
import { environment as env } from '@env/environment'; 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 { AuthState } from '@app/core/auth/auth.models';
import { FiltersDialogComponent, FiltersDialogData } from '@home/components/filter/filters-dialog.component'; import { FiltersDialogComponent, FiltersDialogData } from '@home/components/filter/filters-dialog.component';
import { Filters } from '@shared/models/query/query.models'; 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 { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component';
import { MobileService } from '@core/services/mobile.service'; import { MobileService } from '@core/services/mobile.service';
@ -245,7 +236,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
forceDashboardMobileMode = false; forceDashboardMobileMode = false;
isAddingWidget = false; isAddingWidget = false;
isAddingWidgetClosed = true; isAddingWidgetClosed = true;
filterWidgetTypes: widgetType[] = null;
isToolbarOpened = false; isToolbarOpened = false;
isToolbarOpenedAnimate = false; isToolbarOpenedAnimate = false;
@ -379,7 +369,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
private renderer: Renderer2, private renderer: Renderer2,
private ngZone: NgZone, private ngZone: NgZone,
@Optional() @Inject('embeddedValue') private embeddedValue, @Optional() @Inject('embeddedValue') private embeddedValue,
private overlay: Overlay,
private viewContainerRef: ViewContainerRef, private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
public elRef: ElementRef, public elRef: ElementRef,
@ -1681,59 +1670,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
return widgetContextActions; 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) { public updateDashboardImage($event: Event) {
if ($event) { if ($event) {
$event.stopPropagation(); $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"> <div class="tb-dashboard-widget-select">
<ng-container *ngIf="selectWidgetMode === 'iotHub'; else standardModes"> <mat-toolbar color="primary" class="tb-widget-select-header flex flex-col">
<ng-container *ngTemplateOutlet="iotHubWidgets"></ng-container> <div class="tb-widget-select-title-row flex flex-row items-center">
</ng-container> @if (showTopLevelWidgetModeToggle()) {
<ng-template #standardModes> <tb-toggle-select appearance="fill-invert"
<ng-container *ngIf="selectWidgetMode === 'bundles'; else allWidgets"> [disablePagination]="true"
<ng-container *ngIf="widgetsBundle; else bundles"> 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 *ngTemplateOutlet="widgets; context:{ fetchFunction: widgetsFetchFunction, filter: widgetsFilter }"></ng-container>
</ng-container> } @else if (installedSubMode === 'allWidgets') {
</ng-container> <ng-container *ngTemplateOutlet="widgets; context:{ fetchFunction: allWidgetsFetchFunction, filter: allWidgetsFilter }"></ng-container>
<ng-template #allWidgets> } @else {
<ng-container *ngTemplateOutlet="widgets; context:{ fetchFunction: allWidgetsFetchFunction, filter: allWidgetsFilter }"></ng-container> <tb-scroll-grid
</ng-template> [columns]="columns"
</ng-template> [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"> <ng-template #widgets let-fetchFunction="fetchFunction" let-filter="filter">
<tb-scroll-grid <tb-scroll-grid
[columns]="columns" [columns]="columns"
@ -49,9 +257,11 @@
<div class="title-items-container"> <div class="title-items-container">
<div class="title-items"> <div class="title-items">
<div class="widget-type">{{ 'widget.' + item.widgetType + '-short' | translate }}</div> <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>
<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> </div>
<div class="preview-container"> <div class="preview-container">
@ -69,119 +279,62 @@
</div> </div>
</ng-template> </ng-template>
<ng-template #noWidgets> <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> </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 <tb-scroll-grid
[columns]="columns" [columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy" [itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="widgetBundlesFetchFunction" [fetchFunction]="iotHubWidgetsFetchFunction"
[filter]="widgetsBundleFilter" [filter]="iotHubWidgetsFilter"
[itemCard]="widgetsBundleCard" [itemCard]="iotHubWidgetCard"
[loadingCell]="widgetLoadingCard" [loadingCell]="widgetLoadingCard"
[dataLoading]="loadingWidgetBundles" [dataLoading]="loadingIotHubWidgets"
[noData]="noWidgetBundles"> [noData]="noIotHubWidgets">
</tb-scroll-grid> </tb-scroll-grid>
<ng-template #widgetsBundleCard let-item="item"> <ng-template #iotHubWidgetCard let-item="item">
<mat-card class="tb-widget-preview-card flex flex-col gap-2" appearance="raised" (click)="selectBundle($event, item)"> <ng-container *ngTemplateOutlet="iotHubWidgetCardTpl; context: { $implicit: item, showInstalledBadge: true }"></ng-container>
<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> </ng-template>
<ng-template #loadingWidgetBundles> <ng-template #loadingIotHubWidgets>
<div class="tb-absolute-fill flex flex-col items-center justify-center"> <div class="tb-absolute-fill flex flex-col items-center justify-center">
<span class="mat-headline-5" style="padding-bottom: 20px;"> <span class="mat-headline-5" style="padding-bottom: 20px;">
{{ 'widgets-bundle.loading-widgets-bundles' | translate }} {{ 'widget.loading-widgets' | translate }}
</span> </span>
<mat-spinner strokeWidth="5"></mat-spinner> <mat-spinner color="accent" strokeWidth="5"></mat-spinner>
</div> </div>
</ng-template> </ng-template>
<ng-template #noWidgetBundles> <ng-template #noIotHubWidgets>
<span translate <span class="mat-headline-5 tb-absolute-fill flex items-center justify-center">{{ 'iot-hub.no-items-found' | translate }}</span>
class="mat-headline-5 tb-absolute-fill flex items-center justify-center">widgets-bundle.no-widgets-bundles-text</span>
</ng-template> </ng-template>
</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-template #iotHubInstalledGrid>
<ng-container *ngIf="iotHubInstalledMode === 'all'; else iotHubInstalledGrid"> <tb-scroll-grid
<tb-scroll-grid [columns]="columns"
[columns]="columns" [itemSize]="gridWidgetsItemSizeStrategy"
[itemSize]="gridWidgetsItemSizeStrategy" [fetchFunction]="iotHubInstalledWidgetsFetchFunction"
[fetchFunction]="iotHubWidgetsFetchFunction" [filter]="iotHubInstalledWidgetsFilter"
[filter]="iotHubWidgetsFilter" [itemCard]="iotHubInstalledWidgetCard"
[itemCard]="iotHubWidgetCard" [loadingCell]="widgetLoadingCard"
[loadingCell]="widgetLoadingCard" [dataLoading]="loadingIotHubInstalledWidgets"
[dataLoading]="loadingIotHubWidgets" [noData]="noIotHubInstalledWidgets">
[noData]="noIotHubWidgets"> </tb-scroll-grid>
</tb-scroll-grid> <ng-template #iotHubInstalledWidgetCard let-item="item">
</ng-container> <ng-container *ngTemplateOutlet="iotHubWidgetCardTpl; context: { $implicit: item, showInstalledBadge: false }"></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> </ng-template>
<ng-template #loadingIotHubWidgets> <ng-template #loadingIotHubInstalledWidgets>
<div class="tb-absolute-fill flex flex-col items-center justify-center"> <div class="tb-absolute-fill flex flex-col items-center justify-center">
<span class="mat-headline-5" style="padding-bottom: 20px;"> <span class="mat-headline-5" style="padding-bottom: 20px;">
{{ 'widget.loading-widgets' | translate }} {{ 'widget.loading-widgets' | translate }}
@ -189,139 +342,222 @@
<mat-spinner color="accent" strokeWidth="5"></mat-spinner> <mat-spinner color="accent" strokeWidth="5"></mat-spinner>
</div> </div>
</ng-template> </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> <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> </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 --> <!-- IoT Hub Filter Panel -->
<ng-template #iotHubFilterPanel let-popover="popover"> <ng-template #iotHubFilterPanel let-popover="popover">
<div class="tb-iot-hub-filter-panel"> <div class="tb-iot-hub-filter-panel">
<div class="tb-iot-hub-filter-header"> <div class="tb-iot-hub-filter-header">
<span class="tb-iot-hub-filter-title">{{ 'iot-hub.filters' | translate }}</span> <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> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<div class="tb-iot-hub-filter-sections" [class.filter-items-hovered]="iotHubFilterItemsHovered"> <div class="tb-iot-hub-filter-sections" [class.filter-items-hovered]="iotHubFilterItemsHovered">
<mat-expansion-panel [expanded]="true"> @if (selectWidgetMode === 'installed') {
<mat-expansion-panel-header> @if (widgetTypes.size > 1) {
<mat-panel-title> <mat-expansion-panel [expanded]="true">
{{ 'iot-hub.type' | translate }} <mat-expansion-panel-header>
@if (iotHubPendingWidgetTypes.size) { <mat-panel-title>
<span class="tb-iot-hub-filter-badge">{{ iotHubPendingWidgetTypes.size }}</span> {{ 'iot-hub.type' | translate }}
} @if (filterWidgetTypes?.length) {
</mat-panel-title> <span class="tb-iot-hub-filter-badge">{{ filterWidgetTypes.length }}</span>
</mat-expansion-panel-header> }
@if (iotHubWidgetTypeOptions.length >= 10) { </mat-panel-title>
<div class="tb-iot-hub-filter-search"> </mat-expansion-panel-header>
<mat-icon>search</mat-icon> <div class="tb-iot-hub-filter-items">
<input [placeholder]="'iot-hub.search' | translate" @for (type of widgetTypesList; track type) {
[ngModel]="iotHubFilterSearch['widgetType']" <div class="tb-iot-hub-filter-option">
(ngModelChange)="iotHubFilterSearch['widgetType'] = $event"> <mat-checkbox [checked]="isWidgetTypeChecked(type)"
@if (iotHubFilterSearch['widgetType']) { (change)="toggleWidgetTypeFilter(type)">
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['widgetType'] = ''"> {{ getWidgetTypeLabel(type) }}
<mat-icon>close</mat-icon> </mat-checkbox>
</button> </div>
} }
</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>
</div> </div>
</mat-expansion-panel>
}
@if (hasDeprecated) {
@if (widgetTypes.size > 1) {
<mat-divider></mat-divider>
} }
</div> <div class="tb-iot-hub-filter-option">
</mat-expansion-panel> <mat-checkbox [checked]="includeDeprecated"
<mat-divider></mat-divider> (change)="onIncludeDeprecatedChange($event.checked)">
<mat-expansion-panel [expanded]="false"> {{ 'iot-hub.include-deprecated' | translate }}
<mat-expansion-panel-header> </mat-checkbox>
<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> </div>
} }
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubCategoryOptions, 'category').length > 8" } @else {
(mouseenter)="getFilteredIotHubItems(iotHubCategoryOptions, 'category').length > 8 && (iotHubFilterItemsHovered = true)" <mat-expansion-panel [expanded]="true">
(mouseleave)="iotHubFilterItemsHovered = false"> <mat-expansion-panel-header>
@for (opt of getFilteredIotHubItems(iotHubCategoryOptions, 'category'); track opt.key) { <mat-panel-title>
<div class="tb-iot-hub-filter-option"> {{ 'iot-hub.type' | translate }}
<mat-checkbox [checked]="iotHubPendingCategories.has(opt.key)" @if (iotHubAppliedWidgetTypes.size) {
(change)="toggleIotHubCategory(opt.key)"> <span class="tb-iot-hub-filter-badge">{{ iotHubAppliedWidgetTypes.size }}</span>
{{ opt.key }} }
</mat-checkbox> </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>
} }
</div> <div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType').length > 8"
</mat-expansion-panel> (mouseenter)="getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType').length > 8 && (iotHubFilterItemsHovered = true)"
<mat-divider></mat-divider> (mouseleave)="iotHubFilterItemsHovered = false">
<mat-expansion-panel [expanded]="false"> @for (opt of getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType'); track opt.key) {
<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-option"> <div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubPendingUseCases.has(opt.key)" <mat-checkbox [checked]="iotHubAppliedWidgetTypes.has(opt.key)"
(change)="toggleIotHubUseCase(opt.key)"> (change)="toggleIotHubWidgetType(opt.key)">
{{ opt.key }} {{ getIotHubWidgetTypeLabel(opt.key) }}
</mat-checkbox> </mat-checkbox>
</div> </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> <div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubUseCaseOptions, 'useCase').length > 8"
</mat-expansion-panel> (mouseenter)="getFilteredIotHubItems(iotHubUseCaseOptions, 'useCase').length > 8 && (iotHubFilterItemsHovered = true)"
</div> (mouseleave)="iotHubFilterItemsHovered = false">
<div class="tb-iot-hub-filter-actions"> @for (group of getGroupedIotHubFilterItems(iotHubUseCaseOptions, 'useCase'); track group.label) {
@if (iotHubPendingWidgetTypes.size || iotHubPendingCategories.size || iotHubPendingUseCases.size) { @if (group.label) {
<button mat-stroked-button (click)="clearIotHubFilters()">{{ 'iot-hub.clear-all' | translate }}</button> <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>
</div> </div>
</ng-template> </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 * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
tb-dashboard-widget-select {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.tb-dashboard-widget-select { .tb-dashboard-widget-select {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background-color: #cfd8dc; 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 { .mat-mdc-card.tb-widget-preview-card {
cursor: pointer; cursor: pointer;
@ -163,6 +298,10 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
color: rgba(0, 0, 0, 0.54); color: rgba(0, 0, 0, 0.54);
&.tb-iot-hub-verified-icon {
color: #00695c;
}
} }
} }
@ -202,7 +341,6 @@
// IoT Hub filter panel // IoT Hub filter panel
.tb-iot-hub-filter-panel { .tb-iot-hub-filter-panel {
padding: 12px 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
@ -211,7 +349,21 @@
.tb-iot-hub-filter-header { .tb-iot-hub-filter-header {
display: flex; display: flex;
align-items: center; 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 { .tb-iot-hub-filter-title {
@ -395,6 +547,11 @@
gap: 8px; gap: 8px;
padding-top: 8px; padding-top: 8px;
} }
.tb-widget-select-include-deprecated {
padding: 8px 8px 4px;
}
} }
@keyframes shine { @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. /// 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 { WidgetsBundle } from '@shared/models/widgets-bundle.model';
import { IAliasController } from '@core/api/widget-api.models'; import { IAliasController } from '@core/api/widget-api.models';
import { NULL_UUID } from '@shared/models/id/has-uuid'; 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 { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { MpItemVersionQuery, MpItemVersionView, widgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; 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 { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { import {
@ -45,7 +45,22 @@ import {
IotHubItemDetailDialogData IotHubItemDetailDialogData
} from '@home/components/iot-hub/iot-hub-item-detail-dialog.component'; } 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 { interface WidgetsFilter {
search: string; search: string;
@ -64,7 +79,7 @@ interface BundleWidgetsFilter extends WidgetsFilter {
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,
standalone: false standalone: false
}) })
export class DashboardWidgetSelectComponent implements OnInit { export class DashboardWidgetSelectComponent {
private searchSubject = new BehaviorSubject<string>(''); private searchSubject = new BehaviorSubject<string>('');
private search$ = this.searchSubject.asObservable().pipe( private search$ = this.searchSubject.asObservable().pipe(
@ -72,12 +87,25 @@ export class DashboardWidgetSelectComponent implements OnInit {
private filterWidgetTypes$ = new BehaviorSubject<Array<widgetType>>(null); private filterWidgetTypes$ = new BehaviorSubject<Array<widgetType>>(null);
private deprecatedFilter$ = new BehaviorSubject<DeprecatedFilter>(DeprecatedFilter.ACTUAL); 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); private widgetsBundle$ = new BehaviorSubject<WidgetsBundle>(null);
widgetTypes = new Set<widgetType>(); widgetTypes = new Set<widgetType>();
hasDeprecated = false; 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() @Input()
aliasController: IAliasController; aliasController: IAliasController;
@ -94,7 +122,6 @@ export class DashboardWidgetSelectComponent implements OnInit {
return this.searchSubject.value; return this.searchSubject.value;
} }
@Input()
set filterWidgetTypes(widgetTypes: Array<widgetType>) { set filterWidgetTypes(widgetTypes: Array<widgetType>) {
this.filterWidgetTypes$.next(widgetTypes); this.filterWidgetTypes$.next(widgetTypes);
} }
@ -106,25 +133,21 @@ export class DashboardWidgetSelectComponent implements OnInit {
@Input() @Input()
set selectWidgetMode(mode: selectWidgetMode) { set selectWidgetMode(mode: selectWidgetMode) {
if (this.selectWidgetMode$.value !== mode) { if (this.selectWidgetMode$.value !== mode) {
if (mode === 'bundles' && this.widgetsBundle$.value === null) { this.resetSubModes();
this.widgetTypes.clear();
this.hasDeprecated = false;
} else {
this.widgetTypes = new Set<widgetType>(Object.keys(widgetType).map(t => t as widgetType));
this.hasDeprecated = true;
}
this.filterWidgetTypes$.next(null); this.filterWidgetTypes$.next(null);
this.deprecatedFilter$.next(DeprecatedFilter.ACTUAL); this.deprecatedFilter$.next(DeprecatedFilter.ACTUAL);
this.includeDeprecated = false;
this.selectWidgetMode$.next(mode); this.selectWidgetMode$.next(mode);
this.updateWidgetTypesForCurrentState();
if (mode === 'iotHub') { if (mode === 'iotHub') {
this.loadInstalledWidgets(); this.loadInstalledWidgets();
this.loadIotHubFilterInfo(); this.loadIotHubFilterInfo();
this.loadWidgetCategories();
} else { } else {
this.iotHubInstalledMode = 'all';
this.installedWidgetVersions = null; this.installedWidgetVersions = null;
this.appliedWidgetTypes.clear(); this.iotHubAppliedWidgetTypes.clear();
this.appliedCategories.clear(); this.iotHubAppliedCategories.clear();
this.appliedUseCases.clear(); this.iotHubAppliedUseCases.clear();
this.iotHubFilterCount = 0; this.iotHubFilterCount = 0;
} }
} }
@ -134,6 +157,40 @@ export class DashboardWidgetSelectComponent implements OnInit {
return this.selectWidgetMode$.value; 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() @Input()
set deprecatedFilter(filter: DeprecatedFilter) { set deprecatedFilter(filter: DeprecatedFilter) {
this.deprecatedFilter$.next(filter); this.deprecatedFilter$.next(filter);
@ -145,7 +202,7 @@ export class DashboardWidgetSelectComponent implements OnInit {
set widgetsBundle(widgetBundle: WidgetsBundle) { set widgetsBundle(widgetBundle: WidgetsBundle) {
if (this.widgetsBundle$.value !== widgetBundle) { if (this.widgetsBundle$.value !== widgetBundle) {
if (widgetBundle === null && this.selectWidgetMode$.value !== 'allWidgets') { if (widgetBundle === null) {
this.widgetTypes.clear(); this.widgetTypes.clear();
this.hasDeprecated = false; this.hasDeprecated = false;
} else { } else {
@ -167,6 +224,12 @@ export class DashboardWidgetSelectComponent implements OnInit {
@Output() @Output()
widgetSelected: EventEmitter<WidgetInfo> = new EventEmitter<WidgetInfo>(); widgetSelected: EventEmitter<WidgetInfo> = new EventEmitter<WidgetInfo>();
@Output()
closeClick: EventEmitter<void> = new EventEmitter<void>();
@Output()
importClick: EventEmitter<Event> = new EventEmitter<Event>();
columns: ScrollGridColumns = { columns: ScrollGridColumns = {
columns: 2, columns: 2,
breakpoints: { breakpoints: {
@ -184,36 +247,37 @@ export class DashboardWidgetSelectComponent implements OnInit {
}; };
widgetBundlesFetchFunction: GridEntitiesFetchFunction<WidgetsBundle, string>; widgetBundlesFetchFunction: GridEntitiesFetchFunction<WidgetsBundle, string>;
installedDefaultFetchFunction: GridEntitiesFetchFunction<any, string>;
allWidgetsFetchFunction: GridEntitiesFetchFunction<WidgetTypeInfo, WidgetsFilter>; allWidgetsFetchFunction: GridEntitiesFetchFunction<WidgetTypeInfo, WidgetsFilter>;
widgetsFetchFunction: GridEntitiesFetchFunction<WidgetTypeInfo, BundleWidgetsFilter>; widgetsFetchFunction: GridEntitiesFetchFunction<WidgetTypeInfo, BundleWidgetsFilter>;
iotHubWidgetsFetchFunction: GridEntitiesFetchFunction<MpItemVersionView, string>; 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 = ''; widgetsBundleFilter = '';
allWidgetsFilter: WidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL}; allWidgetsFilter: WidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL};
widgetsFilter: BundleWidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL, widgetsBundleId: null}; widgetsFilter: BundleWidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL, widgetsBundleId: null};
iotHubWidgetsFilter = ''; iotHubWidgetsFilter = '';
iotHubInstalledMode: 'all' | 'installed' = 'all';
iotHubInstalledWidgetsFetchFunction: GridEntitiesFetchFunction<MpItemVersionView, string>;
private installedWidgets: IotHubInstalledItem[] = null;
private installedWidgetVersions: MpItemVersionView[] = null;
iotHubInstalledWidgetsFilter = ''; iotHubInstalledWidgetsFilter = '';
widgetCategoriesFilter = '';
// IoT Hub filter model — applied state (used by fetch functions) private installedWidgets: IotHubInstalledItem[] = null;
private appliedWidgetTypes = new Set<string>(); private installedWidgetVersions: MpItemVersionView[] = null;
private appliedCategories = new Set<string>();
private appliedUseCases = new Set<string>();
// IoT Hub filter model — pending state (used by filter panel UI) // IoT Hub filter model — applied state (used by fetch functions + panel UI)
iotHubPendingWidgetTypes = new Set<string>(); iotHubAppliedWidgetTypes = new Set<string>();
iotHubPendingCategories = new Set<string>(); iotHubAppliedCategories = new Set<string>();
iotHubPendingUseCases = new Set<string>(); iotHubAppliedUseCases = new Set<string>();
iotHubFilterDirty = false;
iotHubWidgetTypeOptions: FilterParamInfo[] = []; iotHubWidgetTypeOptions: FilterParamInfo[] = [];
iotHubCategoryOptions: FilterParamInfo[] = []; iotHubCategoryOptions: FilterParamInfo[] = [];
iotHubUseCaseOptions: FilterParamInfo[] = []; iotHubUseCaseOptions: FilterParamInfo[] = [];
iotHubFilterCount = 0; iotHubFilterCount = 0;
iotHubFilterSearch: Record<string, string> = {}; iotHubFilterSearch: Record<string, string> = {};
iotHubFilterItemsHovered = false;
constructor(private widgetsService: WidgetService, constructor(private widgetsService: WidgetService,
private iotHubApiService: IotHubApiService, private iotHubApiService: IotHubApiService,
@ -249,14 +313,24 @@ export class DashboardWidgetSelectComponent implements OnInit {
this.iotHubWidgetsFetchFunction = (pageSize, page, filter) => { this.iotHubWidgetsFetchFunction = (pageSize, page, filter) => {
const search = typeof filter === 'string' ? filter.split('|')[0] : 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 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, const query = new MpItemVersionQuery(pageLink, ItemType.WIDGET,
undefined, undefined, undefined, undefined,
this.appliedCategories.size > 0 ? Array.from(this.appliedCategories) : undefined, effectiveCategories,
this.appliedUseCases.size > 0 ? Array.from(this.appliedUseCases) : undefined, this.iotHubAppliedUseCases.size > 0 ? Array.from(this.iotHubAppliedUseCases) : undefined,
undefined,
this.iotHubAppliedWidgetTypes.size > 0 ? Array.from(this.iotHubAppliedWidgetTypes) : undefined,
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 }); 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)); 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( this.search$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
skip(1) skip(1)
@ -277,8 +374,10 @@ export class DashboardWidgetSelectComponent implements OnInit {
(search) => { (search) => {
this.widgetsBundleFilter = search; this.widgetsBundleFilter = search;
if (this.selectWidgetMode$.value === 'iotHub') { if (this.selectWidgetMode$.value === 'iotHub') {
if (this.iotHubInstalledMode === 'installed') { if (this.iotHubSubMode$.value === 'installed') {
this.iotHubInstalledWidgetsFilter = search; this.iotHubInstalledWidgetsFilter = search;
} else if (this.iotHubSubMode$.value === 'default') {
this.widgetCategoriesFilter = search;
} else { } else {
this.iotHubWidgetsFilter = search; 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 { onWidgetClicked($event: Event, widget: WidgetTypeInfo): void {
@ -326,10 +454,33 @@ export class DashboardWidgetSelectComponent implements OnInit {
selectBundle($event: Event, bundle: WidgetsBundle) { selectBundle($event: Event, bundle: WidgetsBundle) {
$event.preventDefault(); $event.preventDefault();
this.widgetsBundle = bundle; 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 { onIotHubWidgetClicked($event: Event, item: MpItemVersionView): void {
@ -383,27 +534,6 @@ export class DashboardWidgetSelectComponent implements OnInit {
return null; 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 { getIotHubWidgetTypeLabel(key: string): string {
const translationKey = widgetTypeTranslations.get(key); const translationKey = widgetTypeTranslations.get(key);
return translationKey ? this.translate.instant(translationKey) : key; return translationKey ? this.translate.instant(translationKey) : key;
@ -436,66 +566,196 @@ export class DashboardWidgetSelectComponent implements OnInit {
} }
toggleIotHubWidgetType(key: string): void { toggleIotHubWidgetType(key: string): void {
if (this.iotHubPendingWidgetTypes.has(key)) { if (this.iotHubAppliedWidgetTypes.has(key)) {
this.iotHubPendingWidgetTypes.delete(key); this.iotHubAppliedWidgetTypes.delete(key);
} else { } else {
this.iotHubPendingWidgetTypes.add(key); this.iotHubAppliedWidgetTypes.add(key);
} }
this.updateFilterDirty(); this.onIotHubFiltersChanged();
} }
toggleIotHubCategory(key: string): void { toggleIotHubCategory(key: string): void {
if (this.iotHubPendingCategories.has(key)) { if (this.iotHubAppliedCategories.has(key)) {
this.iotHubPendingCategories.delete(key); this.iotHubAppliedCategories.delete(key);
} else { } else {
this.iotHubPendingCategories.add(key); this.iotHubAppliedCategories.add(key);
} }
this.updateFilterDirty(); this.onIotHubFiltersChanged();
} }
toggleIotHubUseCase(key: string): void { toggleIotHubUseCase(key: string): void {
if (this.iotHubPendingUseCases.has(key)) { if (this.iotHubAppliedUseCases.has(key)) {
this.iotHubPendingUseCases.delete(key); this.iotHubAppliedUseCases.delete(key);
} else { } else {
this.iotHubPendingUseCases.add(key); this.iotHubAppliedUseCases.add(key);
} }
this.updateFilterDirty(); this.onIotHubFiltersChanged();
} }
clearIotHubFilters(): void { clearIotHubFilters(): void {
this.iotHubPendingWidgetTypes.clear(); this.iotHubAppliedWidgetTypes.clear();
this.iotHubPendingCategories.clear(); this.iotHubAppliedCategories.clear();
this.iotHubPendingUseCases.clear(); this.iotHubAppliedUseCases.clear();
this.iotHubFilterSearch = {}; this.iotHubFilterSearch = {};
this.updateFilterDirty(); this.onIotHubFiltersChanged();
} }
applyIotHubFilters(): void { private onIotHubFiltersChanged(): void {
this.appliedWidgetTypes = new Set(this.iotHubPendingWidgetTypes); this.iotHubFilterCount =
this.appliedCategories = new Set(this.iotHubPendingCategories); this.iotHubAppliedWidgetTypes.size +
this.appliedUseCases = new Set(this.iotHubPendingUseCases); (this.iotHubSelectedCategory ? 0 : this.iotHubAppliedCategories.size) +
this.iotHubFilterCount = this.appliedWidgetTypes.size + this.appliedCategories.size + this.appliedUseCases.size; this.iotHubAppliedUseCases.size;
this.iotHubFilterDirty = false;
this.reloadIotHubWidgets(); this.reloadIotHubWidgets();
} }
private updateFilterDirty(): void { onIotHubSortChange(index: number): void {
this.iotHubFilterDirty = if (this.iotHubSelectedSortIndex !== index) {
!this.setsEqual(this.iotHubPendingWidgetTypes, this.appliedWidgetTypes) || this.iotHubSelectedSortIndex = index;
!this.setsEqual(this.iotHubPendingCategories, this.appliedCategories) || this.installedWidgetVersions = null;
!this.setsEqual(this.iotHubPendingUseCases, this.appliedUseCases); 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 { getSearchPlaceholder(): string {
if (a.size !== b.size) { return false; } if (this.selectWidgetMode === 'iotHub') {
for (const v of a) { if (this.iotHubSubMode === 'default') {
if (!b.has(v)) { return false; } 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 { private reloadIotHubWidgets(): void {
if (this.iotHubInstalledMode === 'installed') { if (this.iotHubSubMode === 'installed') {
this.installedWidgetVersions = null; this.installedWidgetVersions = null;
this.iotHubInstalledWidgetsFilter = this.searchSubject.value + '|' + Date.now(); this.iotHubInstalledWidgetsFilter = this.searchSubject.value + '|' + Date.now();
} else { } else {
@ -536,6 +796,10 @@ export class DashboardWidgetSelectComponent implements OnInit {
}); });
} }
private loadWidgetCategories(): void {
this.widgetCategoriesFilter = this.searchSubject.value + '|' + Date.now();
}
private loadInstalledWidgets(): void { private loadInstalledWidgets(): void {
if (this.installedWidgets === null) { if (this.installedWidgets === null) {
this.installedWidgets = []; this.installedWidgets = [];
@ -575,20 +839,46 @@ export class DashboardWidgetSelectComponent implements OnInit {
if (search) { if (search) {
filtered = filtered.filter(v => v.name.toLowerCase().includes(search.toLowerCase())); filtered = filtered.filter(v => v.name.toLowerCase().includes(search.toLowerCase()));
} }
if (this.appliedWidgetTypes.size > 0) { if (this.iotHubAppliedWidgetTypes.size > 0) {
filtered = filtered.filter(v => this.appliedWidgetTypes.has(v.dataDescriptor?.widgetType)); filtered = filtered.filter(v => this.iotHubAppliedWidgetTypes.has(v.dataDescriptor?.widgetType));
} }
if (this.appliedCategories.size > 0) { if (this.iotHubAppliedCategories.size > 0) {
filtered = filtered.filter(v => v.categories?.some(c => this.appliedCategories.has(c))); filtered = filtered.filter(v => v.categories?.some(c => this.iotHubAppliedCategories.has(c)));
} }
if (this.appliedUseCases.size > 0) { if (this.iotHubAppliedUseCases.size > 0) {
filtered = filtered.filter(v => v.useCases?.some(u => this.appliedUseCases.has(u))); filtered = filtered.filter(v => v.useCases?.some(u => this.iotHubAppliedUseCases.has(u)));
} }
filtered = this.sortInstalledVersions(filtered);
const start = page * pageSize; const start = page * pageSize;
const data = filtered.slice(start, start + pageSize); const data = filtered.slice(start, start + pageSize);
return { data, totalPages: Math.ceil(filtered.length / pageSize), totalElements: filtered.length, hasNext: start + pageSize < filtered.length }; 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 { private toWidgetInfo(widgetTypeInfo: WidgetTypeInfo): WidgetInfo {
return { return {
typeFullFqn: fullWidgetTypeFqn(widgetTypeInfo), 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 { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token';
import { EdgeDownlinkTableComponent } from '@home/components/edge/edge-downlink-table.component'; import { EdgeDownlinkTableComponent } from '@home/components/edge/edge-downlink-table.component';
import { EdgeDownlinkTableHeaderComponent } from '@home/components/edge/edge-downlink-table-header.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 { AlarmDurationPredicateValueComponent } from '@home/components/profile/alarm/alarm-duration-predicate-value.component';
import { DashboardImageDialogComponent } from '@home/components/dashboard-page/dashboard-image-dialog.component'; import { DashboardImageDialogComponent } from '@home/components/dashboard-page/dashboard-image-dialog.component';
import { import {
@ -331,7 +330,6 @@ import { SolutionInstallDialogComponent } from '@home/components/solution/soluti
DashboardStateDialogComponent, DashboardStateDialogComponent,
DashboardImageDialogComponent, DashboardImageDialogComponent,
EmbedDashboardDialogComponent, EmbedDashboardDialogComponent,
DisplayWidgetTypesPanelComponent,
TenantProfileQueuesComponent, TenantProfileQueuesComponent,
QueueFormComponent, QueueFormComponent,
RepositorySettingsComponent, RepositorySettingsComponent,
@ -483,7 +481,6 @@ import { SolutionInstallDialogComponent } from '@home/components/solution/soluti
DashboardStateDialogComponent, DashboardStateDialogComponent,
DashboardImageDialogComponent, DashboardImageDialogComponent,
EmbedDashboardDialogComponent, EmbedDashboardDialogComponent,
DisplayWidgetTypesPanelComponent,
TenantProfileQueuesComponent, TenantProfileQueuesComponent,
QueueFormComponent, QueueFormComponent,
RepositorySettingsComponent, 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[]; hardwareTypes: FilterParamInfo[];
connectivities: Record<string, 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 tbVersion?: number,
public hardwareTypes?: string[], public hardwareTypes?: string[],
public connectivity?: string[], public connectivity?: string[],
public vendors?: string[] public vendors?: string[],
public scadaFirst?: boolean
) {} ) {}
public toQuery(): string { public toQuery(): string {
@ -167,6 +168,9 @@ export class MpItemVersionQuery {
if (this.vendors?.length) { if (this.vendors?.length) {
query += this.vendors.map(v => `&vendors=${encodeURIComponent(v)}`).join(''); query += this.vendors.map(v => `&vendors=${encodeURIComponent(v)}`).join('');
} }
if (this.scadaFirst != null) {
query += `&scadaFirst=${this.scadaFirst}`;
}
return query; 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 { ContactComponent } from '@shared/components/contact.component';
import { TimezoneSelectComponent } from '@shared/components/time/timezone-select.component'; import { TimezoneSelectComponent } from '@shared/components/time/timezone-select.component';
import { FileSizePipe } from '@shared/pipe/file-size.pipe'; 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 { SelectableColumnsPipe } from '@shared/pipe/selectable-columns.pipe';
import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component'; import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component';
import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/ota-package-autocomplete.component'; import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/ota-package-autocomplete.component';
@ -408,7 +407,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
EntityGatewaySelectComponent, EntityGatewaySelectComponent,
ContactComponent, ContactComponent,
OtaPackageAutocompleteComponent, OtaPackageAutocompleteComponent,
WidgetsBundleSearchComponent,
CopyButtonComponent, CopyButtonComponent,
TogglePasswordComponent, TogglePasswordComponent,
WidgetButtonToggleComponent, WidgetButtonToggleComponent,
@ -675,7 +673,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
EntityGatewaySelectComponent, EntityGatewaySelectComponent,
ContactComponent, ContactComponent,
OtaPackageAutocompleteComponent, OtaPackageAutocompleteComponent,
WidgetsBundleSearchComponent,
CopyButtonComponent, CopyButtonComponent,
TogglePasswordComponent, TogglePasswordComponent,
WidgetButtonToggleComponent, WidgetButtonToggleComponent,

14
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3711,6 +3711,14 @@
"search-results-for": "Search results for ", "search-results-for": "Search results for ",
"try-adjusting-search": "Try adjusting your search", "try-adjusting-search": "Try adjusting your search",
"add-widget-from-iot-hub": "Add widget from IoT Hub", "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-item-from-iot-hub": "Add {{ type }} from IoT Hub",
"add-from-iot-hub": "Add from IoT Hub", "add-from-iot-hub": "Add from IoT Hub",
"filters": "Filters", "filters": "Filters",
@ -6956,6 +6964,7 @@
"widget-bundle": "Widgets Bundle", "widget-bundle": "Widgets Bundle",
"all-bundles": "All bundles", "all-bundles": "All bundles",
"select-widgets-bundle": "Select widgets bundle", "select-widgets-bundle": "Select widgets bundle",
"installed": "Installed",
"widgets": "Widgets", "widgets": "Widgets",
"all-widgets": "All widgets", "all-widgets": "All widgets",
"widget": "Widget", "widget": "Widget",
@ -6986,6 +6995,11 @@
"rpc-short": "control", "rpc-short": "control",
"alarm-short": "alarm", "alarm-short": "alarm",
"static-short": "static", "static-short": "static",
"type-timeseries": "Timeseries",
"type-latest": "Latest",
"type-rpc": "Control",
"type-alarm": "Alarm",
"type-static": "Static",
"select-widget-type": "Select widget type", "select-widget-type": "Select widget type",
"missing-widget-title-error": "Widget title must be specified!", "missing-widget-title-error": "Widget title must be specified!",
"widget-saved": "Widget saved", "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