From d859573bb69e7ab75d5b0b3829c3790cc213fcb9 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 20 Apr 2026 18:07:50 +0300 Subject: [PATCH] feat(iot-hub): add installed items dialog, extract reusable installed items table - New TbIotHubInstalledItemsDialogComponent opened from detail dialog Manage section - Extract TbIotHubInstalledItemsTableComponent with itemVersion mode for single-item view - Add installed item counts API (GET /installedItems/counts) and itemId filter to installed items list - Wire installed counts into home, browse, search, and detail dialog (replaces installedDevices list) - Propagate ChipOverflowDirective with minChips input; use on detail dialog meta chips - openEntity opens in new tab; "View item details" swaps to "Review device instructions" for DEVICE items --- .../server/controller/IotHubController.java | 5 +- .../dao/iot_hub/IotHubInstalledItemDao.java | 2 +- .../iot_hub/IotHubInstalledItemService.java | 2 +- .../IotHubInstalledItemServiceImpl.java | 4 +- .../IotHubInstalledItemRepository.java | 3 +- .../iot_hub/JpaIotHubInstalledItemDao.java | 3 +- .../src/app/core/http/iot-hub-api.service.ts | 6 +- .../dashboard-widget-select.component.ts | 2 +- .../device-install-dialog.component.scss | 4 + .../iot-hub/iot-hub-browse.component.ts | 8 +- .../iot-hub/iot-hub-components.module.ts | 10 +- ...-hub-installed-items-dialog.component.html | 41 ++ ...-hub-installed-items-dialog.component.scss | 96 +++++ ...ot-hub-installed-items-dialog.component.ts | 52 +++ ...t-hub-installed-items-table.component.html | 162 +++++++ ...t-hub-installed-items-table.component.scss | 274 ++++++++++++ ...iot-hub-installed-items-table.component.ts | 395 ++++++++++++++++++ .../iot-hub-item-detail-dialog.component.html | 102 ++++- .../iot-hub-item-detail-dialog.component.scss | 65 ++- .../iot-hub-item-detail-dialog.component.ts | 24 +- .../iot-hub/iot-hub-search.component.ts | 4 +- .../pages/iot-hub/iot-hub-home.component.ts | 8 +- .../iot-hub-installed-items.component.html | 140 +------ .../iot-hub-installed-items.component.scss | 224 ---------- .../iot-hub-installed-items.component.ts | 329 ++------------- .../directives/chip-overflow.directive.ts | 174 ++++++++ .../src/app/shared/directives/public-api.ts | 1 + ui-ngx/src/app/shared/shared.module.ts | 3 + .../assets/locale/locale.constant-en_US.json | 4 + 29 files changed, 1446 insertions(+), 701 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts create mode 100644 ui-ngx/src/app/shared/directives/chip-overflow.directive.ts diff --git a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java index 39eea5852b..09a30f9266 100644 --- a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java +++ b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java @@ -99,9 +99,10 @@ public class IotHubController extends BaseController { @RequestParam(required = false) String textSearch, @RequestParam(required = false) String sortProperty, @RequestParam(required = false) String sortOrder, - @RequestParam(required = false) List itemTypes) throws ThingsboardException { + @RequestParam(required = false) List itemTypes, + @RequestParam(required = false) UUID itemId) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - return iotHubInstalledItemService.findByTenantId(getTenantId(), itemTypes, pageLink); + return iotHubInstalledItemService.findByTenantId(getTenantId(), itemTypes, itemId, pageLink); } @PreAuthorize("hasAuthority('TENANT_ADMIN')") diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java index e9ff48f9a7..2ad7d1d01a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java @@ -27,7 +27,7 @@ import java.util.UUID; public interface IotHubInstalledItemDao extends Dao { - PageData findByTenantId(TenantId tenantId, List itemTypes, PageLink pageLink); + PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink); List findInstalledItemIdsByTenantId(TenantId tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java index 3374554e54..3fa15852d8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java @@ -31,7 +31,7 @@ public interface IotHubInstalledItemService { IotHubInstalledItem findById(TenantId tenantId, IotHubInstalledItemId id); - PageData findByTenantId(TenantId tenantId, List itemTypes, PageLink pageLink); + PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink); List findInstalledItemIdsByTenantId(TenantId tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java index 42cf853b2b..c98f03d104 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java @@ -47,8 +47,8 @@ class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService { } @Override - public PageData findByTenantId(TenantId tenantId, List itemTypes, PageLink pageLink) { - return iotHubInstalledItemDao.findByTenantId(tenantId, itemTypes, pageLink); + public PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink) { + return iotHubInstalledItemDao.findByTenantId(tenantId, itemTypes, itemId, pageLink); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java index 45f6f988ad..69f93cd5e7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java @@ -36,12 +36,13 @@ interface IotHubInstalledItemRepository extends JpaRepository findByTenantId(@Param("tenantId") UUID tenantId, @Param("itemTypes") List itemTypes, @Param("textSearch") String textSearch, Pageable pageable); + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("itemTypes") List itemTypes, @Param("itemId") UUID itemId, @Param("textSearch") String textSearch, Pageable pageable); @Query(""" SELECT COUNT(item) FROM IotHubInstalledItemEntity item diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java index b644e57e41..b0db650ae0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java @@ -45,10 +45,11 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao findByTenantId(TenantId tenantId, List itemTypes, PageLink pageLink) { + public PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink) { return DaoUtil.toPageData(repository.findByTenantId( tenantId.getId(), itemTypes == null || itemTypes.isEmpty() ? null : itemTypes, + itemId, StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), toPageRequest(pageLink)) ); diff --git a/ui-ngx/src/app/core/http/iot-hub-api.service.ts b/ui-ngx/src/app/core/http/iot-hub-api.service.ts index 6292f17fcf..31367ffef9 100644 --- a/ui-ngx/src/app/core/http/iot-hub-api.service.ts +++ b/ui-ngx/src/app/core/http/iot-hub-api.service.ts @@ -174,12 +174,16 @@ export class IotHubApiService { ); } - public getInstalledItems(pageLink: PageLink, itemTypes?: string | string[], config?: IotHubRequestConfig): Observable> { + public getInstalledItems(pageLink: PageLink, itemTypes?: string | string[], + itemId?: string, config?: IotHubRequestConfig): Observable> { let query = pageLink.toQuery(); if (itemTypes) { const types = Array.isArray(itemTypes) ? itemTypes : [itemTypes]; types.forEach(t => query += `&itemTypes=${t}`); } + if (itemId) { + query += `&itemId=${itemId}`; + } return this.http.get>( `/api/iot-hub/installedItems${query}`, { params: this.buildParams(config) } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts index 71c68bed61..4b5789558b 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts @@ -541,7 +541,7 @@ export class DashboardWidgetSelectComponent implements OnInit { this.installedWidgets = []; } const pageLink = new PageLink(10000, 0); - this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, { ignoreLoading: true }).subscribe(data => { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, { ignoreLoading: true }).subscribe(data => { this.installedWidgets = data.data; }); } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss index 86e2889e7f..2cd707f079 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss @@ -40,6 +40,10 @@ height: 100%; } + .mat-mdc-tab-group { + height: 100%; + } + ::ng-deep { .mat-stepper-horizontal { height: 100%; diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts index c3d3f75e98..f09281d9c5 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts @@ -638,7 +638,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { return; } const pageLink = new PageLink(10000, 0); - this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, {ignoreLoading: true}).subscribe({ + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, {ignoreLoading: true}).subscribe({ next: (data) => { this.installedWidgets = data.data; } @@ -658,7 +658,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { return; } const pageLink = new PageLink(10000, 0); - this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, {ignoreLoading: true}).subscribe({ + this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, undefined, {ignoreLoading: true}).subscribe({ next: (data) => { this.installedSolutionTemplates = data.data; } @@ -669,11 +669,11 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { const config = {ignoreLoading: true}; const pageLink = new PageLink(10000, 0); if (this.activeType === ItemType.WIDGET) { - this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, config).subscribe(data => { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, config).subscribe(data => { this.installedWidgets = data.data; }); } else if (this.activeType === ItemType.SOLUTION_TEMPLATE) { - this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, config).subscribe(data => { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, undefined, config).subscribe(data => { this.installedSolutionTemplates = data.data; }); } else { diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts index 5d636b829e..64a90d5d24 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts @@ -26,6 +26,8 @@ import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component'; import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component'; import { TbIotHubSearchComponent } from './iot-hub-search.component'; +import { TbIotHubInstalledItemsTableComponent } from './iot-hub-installed-items-table.component'; +import { TbIotHubInstalledItemsDialogComponent } from './iot-hub-installed-items-dialog.component'; import { IotHubActionsService } from './iot-hub-actions.service'; @NgModule({ @@ -38,7 +40,9 @@ import { IotHubActionsService } from './iot-hub-actions.service'; TbIotHubUpdateDialogComponent, TbIotHubDeleteDialogComponent, TbDeviceInstallDialogComponent, - TbIotHubSearchComponent + TbIotHubSearchComponent, + TbIotHubInstalledItemsTableComponent, + TbIotHubInstalledItemsDialogComponent ], imports: [ CommonModule, @@ -56,7 +60,9 @@ import { IotHubActionsService } from './iot-hub-actions.service'; TbIotHubUpdateDialogComponent, TbIotHubDeleteDialogComponent, TbDeviceInstallDialogComponent, - TbIotHubSearchComponent + TbIotHubSearchComponent, + TbIotHubInstalledItemsTableComponent, + TbIotHubInstalledItemsDialogComponent ] }) export class IotHubComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html new file mode 100644 index 0000000000..c200ead419 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html @@ -0,0 +1,41 @@ + +
+
+

+ {{ 'iot-hub.installed-items-for' | translate:{ name: item.name } }} +

+ +
+
+ + +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss new file mode 100644 index 0000000000..500b1915ca --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss @@ -0,0 +1,96 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "../../../../../scss/constants"; + +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.tb-iot-hub-installed-items-dialog { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.dlg-header { + display: flex; + align-items: center; + gap: 8px; + height: 64px; + padding: 12px 12px 12px 24px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + flex-shrink: 0; +} + +.dlg-title { + flex: 1 1 auto; + min-width: 0; + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dlg-close { + flex-shrink: 0; +} + +:host-context(.mat-mdc-dialog-container) { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + padding: 24px; + overflow: hidden; + + tb-iot-hub-installed-items-table { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow: hidden; + } + + @media #{$mat-gt-xs} { + height: 500px; + } + + @media #{$mat-gt-sm} { + width: 700px; + } + + @media #{$mat-gt-md} { + width: 800px; + } + } + + .mat-mdc-dialog-actions { + border-top: 1px solid rgba(0, 0, 0, 0.12); + padding: 8px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.ts new file mode 100644 index 0000000000..4c0527821c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.ts @@ -0,0 +1,52 @@ +/// +/// 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 } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; + +export interface IotHubInstalledItemsDialogData { + item: MpItemVersionView; +} + +@Component({ + selector: 'tb-iot-hub-installed-items-dialog', + standalone: false, + templateUrl: './iot-hub-installed-items-dialog.component.html', + styleUrls: ['./iot-hub-installed-items-dialog.component.scss'] +}) +export class TbIotHubInstalledItemsDialogComponent extends DialogComponent { + + item: MpItemVersionView; + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubInstalledItemsDialogData + ) { + super(store, router, dialogRef); + this.item = data.item; + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html new file mode 100644 index 0000000000..59a6a55818 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html @@ -0,0 +1,162 @@ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'iot-hub.item-name' | translate }}{{ item.itemName }}{{ 'iot-hub.item-type' | translate }} + + {{ getItemTypeIcon(item.itemType) }} + {{ getItemTypeLabel(item.itemType) }} + + {{ 'iot-hub.item-version' | translate }}{{ item.version }}{{ 'iot-hub.installed-date' | translate }}{{ item.createdTime | date:'mediumDate' }}@if (updatesChecked) { {{ 'iot-hub.updates' | translate }} } + @if (updatesChecked) { + @if (getPublishedVersionInfo(item); as pubInfo) { + @if (pubInfo.publishedVersionId !== item.itemVersionId) { +
+ @if (!itemVersion) { + + } + +
+ } @else { + + check + {{ 'iot-hub.up-to-date' | translate }} + + } + } @else { + + check + {{ 'iot-hub.up-to-date' | translate }} + + } + } +
+
+
+
+ +
+ @if (!itemVersion || item.itemType === 'DEVICE') { + + } + + +
+ +
+ + + @if (!itemVersion || item.itemType === 'DEVICE') { + + } + + + +
+
+
+ + @if (isLoading) { +
+ +
+ } + + @if (!isLoading && dataSource.length === 0) { +
+ {{ 'iot-hub.no-installed-items' | translate }} +
+ } + + + + + +
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss new file mode 100644 index 0000000000..0ba5d03ca3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss @@ -0,0 +1,274 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "../../../../../scss/constants"; + +:host { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +// Table + paginator container — unified border +.tb-installed-table-container { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + overflow: hidden; + flex: 1; + display: flex; + flex-direction: column; +} + +.tb-installed-table-wrapper { + overflow: auto; + flex: 1; + + table { + width: 100%; + } + + // Header cells — Design: 16px Medium, rgba(0,0,0,0.87), tracking 0.25 + th.mat-mdc-header-cell { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + + // Body cells — Design: 14px Regular, rgba(0,0,0,0.76), tracking 0.2 + td.mat-mdc-cell { + font-size: 14px; + font-weight: 400; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + } +} + + +// Column sizing +.mat-column-itemName { + width: 50%; + min-width: 200px; + max-width: 50%; +} + +.mat-column-itemType { + width: 50%; + min-width: 50%; + max-width: 50%; +} + +.mat-column-version { + width: 80px; + min-width: 80px; + max-width: 80px; +} + +.mat-column-createdTime { + width: 100px; + min-width: 100px; + max-width: 100px; +} + +.mat-column-updates { + width: 250px; + min-width: 250px; + max-width: 250px; +} + +.mat-column-actions { + width: 108px; + min-width: 108px; + max-width: 108px; +} + +::ng-deep .mat-mdc-paginator-range-label { + white-space: nowrap; +} + +// Nowrap columns +.mat-column-itemType, +.mat-column-createdTime, +.mat-column-updates { + white-space: nowrap; +} + +// Type chip — Design: pill, h-32, rounded-16, px-12 py-6, gap-4, bg at 6% opacity +.tb-installed-type { + display: inline-flex; + align-items: center; + gap: 4px; + height: 32px; + padding: 6px 12px; + border-radius: 16px; + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; +} + +.tb-installed-type-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: inherit !important; + --mat-icon-color: currentColor; +} + +.tb-type-widget { color: rgb(32, 115, 61); background: rgba(46, 166, 88, 0.06); } +.tb-type-dashboard { color: rgb(61, 76, 166); background: rgba(61, 76, 166, 0.06); } +.tb-type-solution-template { color: rgb(0, 137, 186); background: rgba(0, 137, 186, 0.06); } +.tb-type-calc-field { color: rgb(40, 120, 148); background: rgba(40, 120, 148, 0.06); } +.tb-type-rule-chain { color: rgb(178, 121, 29); background: rgba(179, 121, 29, 0.06); } +.tb-type-device { color: rgb(24, 146, 110); background: rgba(24, 146, 110, 0.06); } + +// Update row — flex container for version btn + update btn +.tb-installed-update-row { + display: flex; + align-items: center; + gap: 12px; +} + +// "vX.X.X Available" button — Design: border rgba(0,0,0,0.12), rounded-4, px-12 py-6, gap-8 +.tb-installed-version-btn { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.76); + background: none; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; + font-family: inherit; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: inherit !important; + --mat-icon-color: currentColor; + } + + &:hover { + background: rgba(0, 0, 0, 0.04); + } +} + +// "Update" button — Design: same height as available btn, border matches text color +.tb-installed-update-btn.mat-mdc-outlined-button { + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + padding: 6px 12px; + height: auto; + --mat-button-outlined-outline-color: var(--mat-button-outlined-label-text-color); +} + +// "Up to date" — Design: 14px Medium, #198038, gap-8, py-6 +.tb-installed-up-to-date { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + color: #198038; + padding: 6px 0; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: inherit !important; + --mat-icon-color: currentColor; + } +} + +// Actions column +.tb-installed-actions { + display: flex; + align-items: center; + gap: 0; + justify-content: flex-end; + + .mat-mdc-icon-button { + width: 36px; + height: 36px; + padding: 6px; + --mdc-icon-button-state-layer-size: 36px; + --mdc-icon-button-icon-color: rgba(0, 0, 0, 0.54); + color: rgba(0, 0, 0, 0.54); + } +} + +// Loading / Empty +.tb-installed-loading { + display: flex; + justify-content: center; + padding: 48px 0; +} + +.tb-installed-empty { + text-align: center; + padding: 48px 0; + color: rgba(0, 0, 0, 0.54); + font-size: 14px; +} + +:host(:not(.tb-installed-with-version)) { + @media #{$mat-lt-lg} { + .mat-column-actions { + width: 36px; + min-width: 36px; + max-width: 36px; + } + } +} + +:host(.tb-installed-with-version) { + .mat-column-version { + width: 160px; + min-width: 0; + max-width: 160px; + } + + .mat-column-createdTime { + width: 200px; + min-width: 0; + max-width: 200px; + } + + .mat-column-updates { + min-width: 0; + } + + @media #{$mat-lt-md} { + .mat-column-actions { + width: 36px; + min-width: 36px; + max-width: 36px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts new file mode 100644 index 0000000000..ec7ebddae5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts @@ -0,0 +1,395 @@ +/// +/// 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 { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + HostBinding, + Input, + NgZone, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSort } from '@angular/material/sort'; +import { MatPaginator } from '@angular/material/paginator'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { switchMap } from 'rxjs/operators'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { + DeviceInstalledItemDescriptor, + IotHubInstalledItem, + ItemPublishedVersionInfo +} from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL } from '@core/utils'; +import { + IotHubItemDetailDialogData, + TbIotHubItemDetailDialogComponent +} from '@home/components/iot-hub/iot-hub-item-detail-dialog.component'; +import { + IotHubUpdateDialogData, + TbIotHubUpdateDialogComponent +} from '@home/components/iot-hub/iot-hub-update-dialog.component'; +import { + IotHubDeleteDialogData, + TbIotHubDeleteDialogComponent +} from '@home/components/iot-hub/iot-hub-delete-dialog.component'; +import { + DeviceInstallDialogData, + TbDeviceInstallDialogComponent +} from '@home/components/iot-hub/device-install-dialog/device-install-dialog.component'; + +@Component({ + selector: 'tb-iot-hub-installed-items-table', + standalone: false, + templateUrl: './iot-hub-installed-items-table.component.html', + styleUrls: ['./iot-hub-installed-items-table.component.scss'] +}) +export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { + + @Input() textSearch = ''; + @Input() typeFilters: string[] = []; + @Input() itemVersion: MpItemVersionView; + + @HostBinding('class.tb-installed-with-version') get withVersionClass() { + return !!this.itemVersion; + } + + displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'updates', 'actions']; + dataSource: IotHubInstalledItem[] = []; + totalElements = 0; + pageSize = 10; + pageIndex = 0; + hidePageSize = false; + isLoading = false; + + publishedVersionMap = new Map(); + updatesChecked = false; + isCheckingUpdates = false; + + private widgetResize$: ResizeObserver; + + @ViewChild(MatSort, {static: true}) sort: MatSort; + @ViewChild(MatPaginator, {static: true}) paginator: MatPaginator; + + constructor( + private iotHubApiService: IotHubApiService, + private translate: TranslateService, + private store: Store, + private router: Router, + private dialog: MatDialog, + private elementRef: ElementRef, + private zone: NgZone, + private cd: ChangeDetectorRef + ) { + this.widgetResize$ = new ResizeObserver(() => { + this.zone.run(() => { + const shouldHide = this.elementRef.nativeElement.offsetWidth < 640; + if (shouldHide !== this.hidePageSize) { + this.hidePageSize = shouldHide; + this.cd.markForCheck(); + } + }); + }); + this.widgetResize$.observe(this.elementRef.nativeElement); + } + + ngOnInit(): void { + if (this.itemVersion) { + this.displayedColumns = ['version', 'createdTime', 'updates', 'actions']; + this.updatesChecked = true; + this.publishedVersionMap.set(this.itemVersion.itemId, { + itemId: this.itemVersion.itemId, + publishedVersionId: this.itemVersion.id, + publishedVersion: this.itemVersion.version + }); + } + this.loadData(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.textSearch || changes.typeFilters) { + if (!changes.textSearch?.firstChange || !changes.typeFilters?.firstChange) { + this.pageIndex = 0; + if (this.paginator) { + this.paginator.pageIndex = 0; + } + this.loadData(); + } + } + } + + ngAfterViewInit(): void { + this.sort.sortChange.subscribe(() => { + this.pageIndex = 0; + this.paginator.pageIndex = 0; + this.loadData(); + }); + } + + ngOnDestroy(): void { + this.widgetResize$?.disconnect(); + } + + onPageChange(event: any): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.loadData(); + } + + getItemTypeIcon(itemType: string): string { + switch (itemType) { + case 'WIDGET': return 'widgets'; + case 'DASHBOARD': return 'dashboard'; + case 'SOLUTION_TEMPLATE': return 'integration_instructions'; + case 'CALCULATED_FIELD': return 'functions'; + case 'RULE_CHAIN': return 'settings_ethernet'; + case 'DEVICE': return 'memory'; + default: return 'category'; + } + } + + deleteItem(item: IotHubInstalledItem): void { + const dialogRef = this.dialog.open(TbIotHubDeleteDialogComponent, { + panelClass: ['tb-dialog'], + autoFocus: false, + data: { itemName: item.itemName, itemType: item.itemType } as IotHubDeleteDialogData + }); + dialogRef.afterClosed().subscribe(confirmed => { + if (confirmed) { + this.iotHubApiService.deleteInstalledItem(item.id.id).subscribe({ + next: () => { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('iot-hub.installed-item-removed', {name: item.itemName}), + type: 'success', + duration: 3000 + })); + this.loadData(); + }, + error: () => { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('iot-hub.installed-item-remove-error', {name: item.itemName}), + type: 'error', + duration: 5000 + })); + } + }); + } + }); + } + + getItemTypeLabel(itemType: string): string { + const key = itemTypeTranslations.get(itemType as ItemType); + return key ? this.translate.instant(key) : itemType; + } + + getItemTypeChipClass(itemType: string): string { + switch (itemType) { + case 'WIDGET': return 'tb-type-widget'; + case 'DASHBOARD': return 'tb-type-dashboard'; + case 'CALCULATED_FIELD': return 'tb-type-calc-field'; + case 'RULE_CHAIN': return 'tb-type-rule-chain'; + case 'DEVICE': return 'tb-type-device'; + case 'SOLUTION_TEMPLATE': return 'tb-type-solution-template'; + default: return ''; + } + } + + viewItemDetails(item: IotHubInstalledItem): void { + if (item.itemType === 'DEVICE') { + this.openDeviceReviewDialog(item); + return; + } + this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { + const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + autoFocus: false, + data: { + item: versionView, + installedItem: item + } as IotHubItemDetailDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'updated' || result === 'deleted') { + this.loadData(); + } + }); + }); + } + + private openDeviceReviewDialog(item: IotHubInstalledItem): void { + const descriptor = item.descriptor as DeviceInstalledItemDescriptor; + if (!descriptor.installState || !descriptor.selectedInstallMethod) { + this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { + this.dialog.open(TbIotHubItemDetailDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + autoFocus: false, + data: { item: versionView, installedItem: item } as IotHubItemDetailDialogData + }); + }); + return; + } + this.iotHubApiService.getVersionFileData(item.itemVersionId, { ignoreLoading: true, ignoreErrors: true }).subscribe({ + next: async (blob: Blob) => { + const zipData = await blob.arrayBuffer(); + this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true, ignoreErrors: true}).subscribe(versionView => { + this.dialog.open(TbDeviceInstallDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'], + disableClose: false, + autoFocus: false, + data: { + item: versionView, + zipData, + reviewMode: true, + selectedInstallMethod: descriptor.selectedInstallMethod, + installState: descriptor.installState + } as DeviceInstallDialogData + }); + }); + } + }); + } + + getEntityId(item: IotHubInstalledItem): string | null { + const descriptor = item.descriptor; + switch (descriptor.type) { + case 'WIDGET': return descriptor.widgetTypeId?.id; + case 'DASHBOARD': return descriptor.dashboardId?.id; + case 'CALCULATED_FIELD': return descriptor.entityId?.id; + case 'RULE_CHAIN': return descriptor.ruleChainId?.id; + case 'DEVICE': return descriptor.dashboardId?.id ?? null; + case 'SOLUTION_TEMPLATE': return descriptor.dashboardId?.id; + default: return null; + } + } + + getEntityType(item: IotHubInstalledItem): EntityType | null { + const descriptor = item.descriptor; + switch (descriptor.type) { + case 'WIDGET': return EntityType.WIDGET_TYPE; + case 'DASHBOARD': return EntityType.DASHBOARD; + case 'CALCULATED_FIELD': return descriptor.entityId?.entityType as EntityType; + case 'RULE_CHAIN': return EntityType.RULE_CHAIN; + case 'DEVICE': return descriptor.dashboardId ? EntityType.DASHBOARD : null; + case 'SOLUTION_TEMPLATE': return EntityType.DASHBOARD; + default: return null; + } + } + + openEntity(item: IotHubInstalledItem): void { + const entityType = this.getEntityType(item); + const entityId = this.getEntityId(item); + if (entityType && entityId) { + const url = getEntityDetailsPageURL(entityId, entityType); + if (url) { + window.open(this.router.serializeUrl(this.router.parseUrl(url)), '_blank'); + } + } + } + + checkForUpdates(): void { + this.isCheckingUpdates = true; + this.iotHubApiService.getInstalledItemIds({ ignoreLoading: true }).pipe( + switchMap(itemIds => this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true })) + ).subscribe({ + next: (infos) => { + this.publishedVersionMap.clear(); + infos.forEach(info => this.publishedVersionMap.set(info.itemId, info)); + this.updatesChecked = true; + this.isCheckingUpdates = false; + }, + error: () => { + this.isCheckingUpdates = false; + } + }); + } + + viewUpdateDetails(publishedInfo: ItemPublishedVersionInfo, installedItem: IotHubInstalledItem): void { + this.iotHubApiService.getVersionInfo(publishedInfo.publishedVersionId, {ignoreLoading: true}).subscribe(versionView => { + const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + autoFocus: false, + data: { + item: versionView, + installedItem + } as IotHubItemDetailDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'updated' || result === 'deleted') { + this.loadData(); + } + }); + }); + } + + updateItem(item: IotHubInstalledItem, publishedInfo: ItemPublishedVersionInfo): void { + const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { + panelClass: ['tb-dialog'], + data: { + installedItemId: item.id.id, + itemName: item.itemName, + itemType: item.itemType as ItemType, + version: publishedInfo.publishedVersion, + versionId: publishedInfo.publishedVersionId, + } as IotHubUpdateDialogData + }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'updated') { + this.loadData(); + } + }); + } + + getPublishedVersionInfo(item: IotHubInstalledItem): ItemPublishedVersionInfo | undefined { + return this.publishedVersionMap.get(item.itemId); + } + + private loadData(): void { + this.isLoading = true; + const sortOrder: SortOrder = { + property: this.sort?.active || 'createdTime', + direction: this.sort?.direction === 'asc' ? Direction.ASC : Direction.DESC + }; + const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder); + const typeFilters = this.typeFilters?.length ? this.typeFilters : null; + this.iotHubApiService.getInstalledItems(pageLink, typeFilters, this.itemVersion?.itemId, {ignoreLoading: true}).subscribe({ + next: (data) => { + this.dataSource = data.data; + this.totalElements = data.totalElements; + this.isLoading = false; + }, + error: () => { + this.dataSource = []; + this.totalElements = 0; + this.isLoading = false; + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html index 46f07934ec..846d32ce43 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html @@ -35,10 +35,6 @@ download {{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }} - @if (getSubtypeLabel(); as subtypeLabel) { - - {{ subtypeLabel }} - } update @@ -177,7 +173,7 @@
-
+
@if (showCreator) {
{{ 'iot-hub.creator' | translate }} @@ -196,19 +192,27 @@
} + @if (getSubtypeLabel(); as subtypeLabel) { +
+ {{ 'iot-hub.type' | translate }} +
+ {{ subtypeLabel }} +
+
+ } @if (item.type === ItemType.DEVICE) { @if (item.dataDescriptor?.hardwareType) { -
+
{{ 'iot-hub.hardware-type' | translate }}
- {{ item.dataDescriptor.hardwareType }} + {{ item.dataDescriptor.hardwareType }}
} @if (item.dataDescriptor?.connectivity?.length) { -
+
{{ 'iot-hub.connectivity' | translate }} -
+
@for (conn of item.dataDescriptor.connectivity; track conn) { {{ conn }} } @@ -217,9 +221,9 @@ } } @else { @if (item.categories?.length) { -
+
{{ 'iot-hub.category' | translate }} -
+
@for (cat of item.categories; track cat) { {{ cat }} } @@ -228,9 +232,9 @@ } } @if (item.useCases?.length) { -
+
{{ 'iot-hub.use-cases' | translate }} -
+
@for (uc of item.useCases; track uc) { {{ uc }} } @@ -270,6 +274,21 @@
} + @if (hasDetails()) { +
+ {{ 'iot-hub.details' | translate }} + +
+ }
@@ -329,3 +348,60 @@ }
+ + +
+ {{ 'iot-hub.details' | translate }} +
+ @if (getSubtypeLabel(); as subtypeLabel) { +
+ {{ 'iot-hub.type' | translate }} +
+ {{ subtypeLabel }} +
+
+ } + @if (item.type === ItemType.DEVICE) { + @if (item.dataDescriptor?.hardwareType) { +
+ {{ 'iot-hub.hardware-type' | translate }} +
+ {{ item.dataDescriptor.hardwareType }} +
+
+ } + @if (item.dataDescriptor?.connectivity?.length) { +
+ {{ 'iot-hub.connectivity' | translate }} +
+ @for (conn of item.dataDescriptor.connectivity; track conn) { + {{ conn }} + } +
+
+ } + } @else { + @if (item.categories?.length) { +
+ {{ 'iot-hub.category' | translate }} +
+ @for (cat of item.categories; track cat) { + {{ cat }} + } +
+
+ } + } + @if (item.useCases?.length) { +
+ {{ 'iot-hub.use-cases' | translate }} +
+ @for (uc of item.useCases; track uc) { + {{ uc }} + } +
+
+ } +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss index 5c73db517b..07d16c8f66 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss @@ -284,11 +284,57 @@ background: white; } -// Meta section (categories + use cases) — Design: px-24, py-16, gap-24, flex row +// Meta section (categories + use cases) — Design: px-24, py-16, gap-24, grid row .dlg-meta { - display: flex; + display: grid; gap: 24px; padding: 16px 24px 12px; + grid-template-columns: max-content minmax(0, max-content) minmax(auto, max-content) minmax(auto, 1fr) max-content; + + &.dlg-meta-4-col { + grid-template-columns: max-content minmax(auto, max-content) minmax(auto, 1fr) max-content; + } + + .dlg-meta-details { + display: none; + } + + @media #{$mat-lt-md} { + grid-template-columns: max-content max-content max-content; + + &.dlg-meta-4-col { + grid-template-columns: max-content max-content max-content; + } + + .dlg-meta-details-section { + display: none; + } + + .dlg-meta-details { + display: flex; + } + } +} + +.dlg-details-popover { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + + .dlg-details-popover-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + + .dlg-details-popover-body { + display: flex; + flex-direction: column; + gap: 8px; + } } .dlg-meta-group { @@ -355,6 +401,9 @@ line-height: 16px; letter-spacing: 0.25px; color: rgba(0, 0, 0, 0.54); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .dlg-meta-chips { @@ -400,12 +449,24 @@ white-space: nowrap; } +.dlg-chip.dlg-chip-inline { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; +} + // Category chip — Design: bg rgba(16,108,200,0.06), color #106cc8 .dlg-chip-category { background: rgba(16, 108, 200, 0.06); color: #106cc8; } +// Type chip — Design: bg rgba(0,105,92,0.06), color #00695c +.dlg-chip-type { + background: rgba(0, 105, 92, 0.06); + color: #00695c; +} + // Use case chip — Design: bg rgba(25,128,56,0.06), color #198038 .dlg-chip-usecase { background: rgba(25, 128, 56, 0.06); diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts index 7518b1b803..e4e59b04c7 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -33,6 +33,10 @@ import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from '@home/com import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from '@home/components/iot-hub/device-install-dialog/device-install-dialog.component'; import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { + IotHubInstalledItemsDialogData, + TbIotHubInstalledItemsDialogComponent +} from '@home/components/iot-hub/iot-hub-installed-items-dialog.component'; export type IotHubItemDetailDialogMode = 'default' | 'add'; @@ -374,7 +378,25 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, config).subscribe(data => { this.installedWidgets = data.data; }); } else if (type === ItemType.SOLUTION_TEMPLATE) { - this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, config).subscribe(data => { + this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, undefined, config).subscribe(data => { this.installedSolutionTemplates = data.data; }); } else if (type === ItemType.DEVICE) { @@ -483,8 +483,8 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { calcFields: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.CALCULATED_FIELD, this.compactCardCount), config), ruleChains: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.RULE_CHAIN, this.compactCardCount), config), devices: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DEVICE, this.bigCardCount), config), - installedWidgets: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.WIDGET, config), - installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, config), + installedWidgets: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.WIDGET, undefined, config), + installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, undefined, config), installedDeviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config), installedDashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config), installedCalcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config), diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html index 4b5cd00e52..763a759ccf 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html @@ -78,140 +78,8 @@
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'iot-hub.item-name' | translate }}{{ item.itemName }}{{ 'iot-hub.item-type' | translate }} - - {{ getItemTypeIcon(item.itemType) }} - {{ getItemTypeLabel(item.itemType) }} - - {{ 'iot-hub.item-version' | translate }}{{ item.version }}{{ 'iot-hub.installed-date' | translate }}{{ item.createdTime | date:'mediumDate' }}@if (updatesChecked) { {{ 'iot-hub.updates' | translate }} } - @if (updatesChecked) { - @if (getPublishedVersionInfo(item); as pubInfo) { - @if (pubInfo.publishedVersionId !== item.itemVersionId) { -
- - -
- } @else { - - check - {{ 'iot-hub.up-to-date' | translate }} - - } - } @else { - - check - {{ 'iot-hub.up-to-date' | translate }} - - } - } -
-
-
-
- -
- - - -
- -
- - - - - - -
-
-
- - @if (isLoading) { -
- -
- } - - @if (!isLoading && dataSource.length === 0) { -
- {{ 'iot-hub.no-installed-items' | translate }} -
- } - - - - - -
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss index 9453fdc1a1..cd6ba7e579 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss @@ -148,223 +148,6 @@ } } - -// Table + paginator container — unified border -.tb-installed-table-container { - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 8px; - overflow: hidden; - flex: 1; - display: flex; - flex-direction: column; -} - -.tb-installed-table-wrapper { - overflow: auto; - flex: 1; - - table { - width: 100%; - } - - // Header cells — Design: 16px Medium, rgba(0,0,0,0.87), tracking 0.25 - th.mat-mdc-header-cell { - font-size: 16px; - font-weight: 500; - line-height: 24px; - letter-spacing: 0.25px; - color: rgba(0, 0, 0, 0.87); - } - - // Body cells — Design: 14px Regular, rgba(0,0,0,0.76), tracking 0.2 - td.mat-mdc-cell { - font-size: 14px; - font-weight: 400; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - } -} - - -// Column sizing -.mat-column-itemName { - width: 50%; - min-width: 200px; - max-width: 50%; -} - -.mat-column-itemType { - width: 50%; - min-width: 50%; - max-width: 50%; -} - -.mat-column-version { - width: 80px; - min-width: 80px; - max-width: 80px; -} - -.mat-column-createdTime { - width: 100px; - min-width: 100px; - max-width: 100px; -} - -.mat-column-updates { - width: 250px; - min-width: 250px; - max-width: 250px; -} - -.mat-column-actions { - width: 108px; - min-width: 108px; - max-width: 108px; -} - -::ng-deep .mat-mdc-paginator-range-label { - white-space: nowrap; -} - -// Nowrap columns -.mat-column-itemType, -.mat-column-createdTime, -.mat-column-updates { - white-space: nowrap; -} - -// Type chip — Design: pill, h-32, rounded-16, px-12 py-6, gap-4, bg at 6% opacity -.tb-installed-type { - display: inline-flex; - align-items: center; - gap: 4px; - height: 32px; - padding: 6px 12px; - border-radius: 16px; - font-size: 14px; - font-weight: 400; - line-height: 20px; - letter-spacing: 0.2px; -} - -.tb-installed-type-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: inherit !important; - --mat-icon-color: currentColor; -} - -.tb-type-widget { color: rgb(32, 115, 61); background: rgba(46, 166, 88, 0.06); } -.tb-type-dashboard { color: rgb(61, 76, 166); background: rgba(61, 76, 166, 0.06); } -.tb-type-solution-template { color: rgb(0, 137, 186); background: rgba(0, 137, 186, 0.06); } -.tb-type-calc-field { color: rgb(40, 120, 148); background: rgba(40, 120, 148, 0.06); } -.tb-type-rule-chain { color: rgb(178, 121, 29); background: rgba(179, 121, 29, 0.06); } -.tb-type-device { color: rgb(24, 146, 110); background: rgba(24, 146, 110, 0.06); } - -// Updates column — Design: 14px Medium, tracking 0.25, gap-8, icons 18px - -// Update row — flex container for version btn + update btn -.tb-installed-update-row { - display: flex; - align-items: center; - gap: 12px; -} - -// "vX.X.X Available" button — Design: border rgba(0,0,0,0.12), rounded-4, px-12 py-6, gap-8 -.tb-installed-version-btn { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 500; - line-height: 20px; - letter-spacing: 0.25px; - color: rgba(0, 0, 0, 0.76); - background: none; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - padding: 6px 12px; - cursor: pointer; - font-family: inherit; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: inherit !important; - --mat-icon-color: currentColor; - } - - &:hover { - background: rgba(0, 0, 0, 0.04); - } -} - -// "Update" button — Design: same height as available btn, border matches text color -.tb-installed-update-btn.mat-mdc-outlined-button { - font-size: 14px; - font-weight: 500; - line-height: 20px; - letter-spacing: 0.25px; - padding: 6px 12px; - height: auto; - --mat-button-outlined-outline-color: var(--mat-button-outlined-label-text-color); -} - -// "Up to date" — Design: 14px Medium, #198038, gap-8, py-6 -.tb-installed-up-to-date { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 500; - line-height: 20px; - letter-spacing: 0.25px; - color: #198038; - padding: 6px 0; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: inherit !important; - --mat-icon-color: currentColor; - } -} - -// Actions column -.tb-installed-actions { - display: flex; - align-items: center; - gap: 0; - justify-content: flex-end; - - .mat-mdc-icon-button { - width: 36px; - height: 36px; - padding: 6px; - --mdc-icon-button-state-layer-size: 36px; - --mdc-icon-button-icon-color: rgba(0, 0, 0, 0.54); - color: rgba(0, 0, 0, 0.54); - } -} - -// Loading / Empty -.tb-installed-loading { - display: flex; - justify-content: center; - padding: 48px 0; -} - -.tb-installed-empty { - text-align: center; - padding: 48px 0; - color: rgba(0, 0, 0, 0.54); - font-size: 14px; -} - // Responsive @media #{$mat-lt-lg} { .tb-installed-toolbar { @@ -392,12 +175,6 @@ width: 100%; order: 9; } - - .mat-column-actions { - width: 36px; - min-width: 36px; - max-width: 36px; - } } @media #{$mat-lt-md} { @@ -421,5 +198,4 @@ .tb-installed-search { width: 200px; } - } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts index 86250ed163..b1d0ecfdcb 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts @@ -14,30 +14,13 @@ /// limitations under the License. /// -import { Component, OnInit, AfterViewInit, OnDestroy, ViewChild, ElementRef, NgZone, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { MatDialog } from '@angular/material/dialog'; -import { MatSort } from '@angular/material/sort'; -import { MatPaginator } from '@angular/material/paginator'; -import { IotHubApiService } from '@core/http/iot-hub-api.service'; -import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { ActionNotificationShow } from '@core/notification/notification.actions'; import { Subject } from 'rxjs'; -import { debounceTime, switchMap } from 'rxjs/operators'; -import { PageLink } from '@shared/models/page/page-link'; -import { Direction, SortOrder } from '@shared/models/page/sort-order'; -import { IotHubInstalledItem, ItemPublishedVersionInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { debounceTime, takeUntil } from 'rxjs/operators'; import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; -import { EntityType } from '@shared/models/entity-type.models'; -import { getEntityDetailsPageURL } from '@core/utils'; -import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from '@home/components/iot-hub/iot-hub-item-detail-dialog.component'; -import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from '@home/components/iot-hub/iot-hub-update-dialog.component'; -import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from '@home/components/iot-hub/iot-hub-delete-dialog.component'; -import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from '@home/components/iot-hub/device-install-dialog/device-install-dialog.component'; -import { DeviceInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { TbIotHubInstalledItemsTableComponent } from '@home/components/iot-hub/iot-hub-installed-items-table.component'; @Component({ selector: 'tb-iot-hub-installed-items', @@ -45,82 +28,44 @@ import { DeviceInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-in templateUrl: './iot-hub-installed-items.component.html', styleUrls: ['./iot-hub-installed-items.component.scss'] }) -export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit, OnDestroy { +export class TbIotHubInstalledItemsComponent implements OnInit, OnDestroy { - displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'updates', 'actions']; - dataSource: IotHubInstalledItem[] = []; - totalElements = 0; - pageSize = 10; - hidePageSize = false; - - private widgetResize$: ResizeObserver; - pageIndex = 0; - isLoading = false; textSearch = ''; + appliedTextSearch = ''; + typeFilters: string[] = []; - publishedVersionMap = new Map(); - updatesChecked = false; - isCheckingUpdates = false; - - // Type filter filterOpen = false; activeTypeFilters = new Set(); allItemTypes: string[] = ['WIDGET', 'DASHBOARD', 'SOLUTION_TEMPLATE', 'CALCULATED_FIELD', 'RULE_CHAIN', 'DEVICE']; - private searchSubject = new Subject(); + @ViewChild(TbIotHubInstalledItemsTableComponent) tableComponent: TbIotHubInstalledItemsTableComponent; - @ViewChild(MatSort, {static: true}) sort: MatSort; - @ViewChild(MatPaginator, {static: true}) paginator: MatPaginator; + private searchSubject = new Subject(); + private destroy$ = new Subject(); constructor( - private iotHubApiService: IotHubApiService, - private dialogService: DialogService, private translate: TranslateService, - private store: Store, private route: ActivatedRoute, - private router: Router, - private dialog: MatDialog, - private elementRef: ElementRef, - private zone: NgZone, - private cd: ChangeDetectorRef - ) { - this.widgetResize$ = new ResizeObserver(() => { - this.zone.run(() => { - const shouldHide = this.elementRef.nativeElement.offsetWidth < 640; - if (shouldHide !== this.hidePageSize) { - this.hidePageSize = shouldHide; - this.cd.markForCheck(); - } - }); - }); - this.widgetResize$.observe(this.elementRef.nativeElement); - } + private router: Router + ) {} ngOnInit(): void { const itemType = this.route.snapshot.queryParamMap.get('itemType'); if (itemType && this.allItemTypes.includes(itemType)) { this.activeTypeFilters.add(itemType); + this.typeFilters = Array.from(this.activeTypeFilters); } this.searchSubject.pipe( - debounceTime(300) - ).subscribe(() => { - this.pageIndex = 0; - this.paginator.pageIndex = 0; - this.loadData(); + debounceTime(300), + takeUntil(this.destroy$) + ).subscribe(value => { + this.appliedTextSearch = value; }); - this.loadData(); } ngOnDestroy(): void { - this.widgetResize$?.disconnect(); - } - - ngAfterViewInit(): void { - this.sort.sortChange.subscribe(() => { - this.pageIndex = 0; - this.paginator.pageIndex = 0; - this.loadData(); - }); + this.destroy$.next(); + this.destroy$.complete(); } navigateToMarketplace(): void { @@ -132,12 +77,6 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit, O this.searchSubject.next(value); } - onPageChange(event: any): void { - this.pageIndex = event.pageIndex; - this.pageSize = event.pageSize; - this.loadData(); - } - toggleFilter(): void { this.filterOpen = !this.filterOpen; } @@ -152,253 +91,37 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit, O } else { this.activeTypeFilters.add(type); } - this.pageIndex = 0; - this.loadData(); + this.typeFilters = Array.from(this.activeTypeFilters); } removeTypeFilter(type: string): void { this.activeTypeFilters.delete(type); - this.pageIndex = 0; - this.loadData(); + this.typeFilters = Array.from(this.activeTypeFilters); } clearAllFilters(): void { this.activeTypeFilters.clear(); - this.pageIndex = 0; - this.loadData(); + this.typeFilters = []; } hasActiveFilters(): boolean { return this.activeTypeFilters.size > 0; } - getItemTypeIcon(itemType: string): string { - switch (itemType) { - case 'WIDGET': return 'widgets'; - case 'DASHBOARD': return 'dashboard'; - case 'SOLUTION_TEMPLATE': return 'integration_instructions'; - case 'CALCULATED_FIELD': return 'functions'; - case 'RULE_CHAIN': return 'settings_ethernet'; - case 'DEVICE': return 'memory'; - default: return 'category'; - } - } - - deleteItem(item: IotHubInstalledItem): void { - const dialogRef = this.dialog.open(TbIotHubDeleteDialogComponent, { - panelClass: ['tb-dialog'], - autoFocus: false, - data: { itemName: item.itemName, itemType: item.itemType } as IotHubDeleteDialogData - }); - dialogRef.afterClosed().subscribe(confirmed => { - if (confirmed) { - this.iotHubApiService.deleteInstalledItem(item.id.id).subscribe({ - next: () => { - this.store.dispatch(new ActionNotificationShow({ - message: this.translate.instant('iot-hub.installed-item-removed', {name: item.itemName}), - type: 'success', - duration: 3000 - })); - this.loadData(); - }, - error: () => { - this.store.dispatch(new ActionNotificationShow({ - message: this.translate.instant('iot-hub.installed-item-remove-error', {name: item.itemName}), - type: 'error', - duration: 5000 - })); - } - }); - } - }); - } - getItemTypeLabel(itemType: string): string { const key = itemTypeTranslations.get(itemType as ItemType); return key ? this.translate.instant(key) : itemType; } - getItemTypeChipClass(itemType: string): string { - switch (itemType) { - case 'WIDGET': return 'tb-type-widget'; - case 'DASHBOARD': return 'tb-type-dashboard'; - case 'CALCULATED_FIELD': return 'tb-type-calc-field'; - case 'RULE_CHAIN': return 'tb-type-rule-chain'; - case 'DEVICE': return 'tb-type-device'; - case 'SOLUTION_TEMPLATE': return 'tb-type-solution-template'; - default: return ''; - } - } - - viewItemDetails(item: IotHubInstalledItem): void { - if (item.itemType === 'DEVICE') { - this.openDeviceReviewDialog(item); - return; - } - this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { - const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - autoFocus: false, - data: { - item: versionView, - installedItem: item - } as IotHubItemDetailDialogData - }); - dialogRef.afterClosed().subscribe(result => { - if (result === 'updated' || result === 'deleted') { - this.loadData(); - } - }); - }); - } - - private openDeviceReviewDialog(item: IotHubInstalledItem): void { - const descriptor = item.descriptor as DeviceInstalledItemDescriptor; - if (!descriptor.installState || !descriptor.selectedInstallMethod) { - // No install state — fall back to regular detail dialog - this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { - this.dialog.open(TbIotHubItemDetailDialogComponent, { - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - autoFocus: false, - data: { item: versionView, installedItem: item } as IotHubItemDetailDialogData - }); - }); - return; - } - this.iotHubApiService.getVersionFileData(item.itemVersionId, { ignoreLoading: true, ignoreErrors: true }).subscribe({ - next: async (blob: Blob) => { - const zipData = await blob.arrayBuffer(); - this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true, ignoreErrors: true}).subscribe(versionView => { - this.dialog.open(TbDeviceInstallDialogComponent, { - panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'], - disableClose: false, - autoFocus: false, - data: { - item: versionView, - zipData, - reviewMode: true, - selectedInstallMethod: descriptor.selectedInstallMethod, - installState: descriptor.installState - } as DeviceInstallDialogData - }); - }); - } - }); - } - - getEntityId(item: IotHubInstalledItem): string | null { - const descriptor = item.descriptor; - switch (descriptor.type) { - case 'WIDGET': return descriptor.widgetTypeId?.id; - case 'DASHBOARD': return descriptor.dashboardId?.id; - case 'CALCULATED_FIELD': return descriptor.entityId?.id; - case 'RULE_CHAIN': return descriptor.ruleChainId?.id; - case 'DEVICE': return descriptor.dashboardId?.id ?? null; - case 'SOLUTION_TEMPLATE': return descriptor.dashboardId?.id; - default: return null; - } - } - - getEntityType(item: IotHubInstalledItem): EntityType | null { - const descriptor = item.descriptor; - switch (descriptor.type) { - case 'WIDGET': return EntityType.WIDGET_TYPE; - case 'DASHBOARD': return EntityType.DASHBOARD; - case 'CALCULATED_FIELD': return descriptor.entityId?.entityType as EntityType; - case 'RULE_CHAIN': return EntityType.RULE_CHAIN; - case 'DEVICE': return descriptor.dashboardId ? EntityType.DASHBOARD : null; - case 'SOLUTION_TEMPLATE': return EntityType.DASHBOARD; - default: return null; - } - } - - openEntity(item: IotHubInstalledItem): void { - const entityType = this.getEntityType(item); - const entityId = this.getEntityId(item); - if (entityType && entityId) { - const url = getEntityDetailsPageURL(entityId, entityType); - if (url) { - this.router.navigateByUrl(url); - } - } - } - checkForUpdates(): void { - this.isCheckingUpdates = true; - this.iotHubApiService.getInstalledItemIds({ ignoreLoading: true }).pipe( - switchMap(itemIds => this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true })) - ).subscribe({ - next: (infos) => { - this.publishedVersionMap.clear(); - infos.forEach(info => this.publishedVersionMap.set(info.itemId, info)); - this.updatesChecked = true; - this.isCheckingUpdates = false; - }, - error: () => { - this.isCheckingUpdates = false; - } - }); + this.tableComponent?.checkForUpdates(); } - viewUpdateDetails(publishedInfo: ItemPublishedVersionInfo, installedItem: IotHubInstalledItem): void { - this.iotHubApiService.getVersionInfo(publishedInfo.publishedVersionId, {ignoreLoading: true}).subscribe(versionView => { - const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - autoFocus: false, - data: { - item: versionView, - installedItem - } as IotHubItemDetailDialogData - }); - dialogRef.afterClosed().subscribe(result => { - if (result === 'updated' || result === 'deleted') { - this.loadData(); - } - }); - }); + get isCheckingUpdates(): boolean { + return this.tableComponent?.isCheckingUpdates || false; } - updateItem(item: IotHubInstalledItem, publishedInfo: ItemPublishedVersionInfo): void { - const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { - panelClass: ['tb-dialog'], - data: { - installedItemId: item.id.id, - itemName: item.itemName, - itemType: item.itemType as ItemType, - version: publishedInfo.publishedVersion, - versionId: publishedInfo.publishedVersionId, - } as IotHubUpdateDialogData - }); - dialogRef.afterClosed().subscribe(result => { - if (result === 'updated') { - this.loadData(); - } - }); - } - - getPublishedVersionInfo(item: IotHubInstalledItem): ItemPublishedVersionInfo | undefined { - return this.publishedVersionMap.get(item.itemId); - } - - private loadData(): void { - this.isLoading = true; - const sortOrder: SortOrder = { - property: this.sort?.active || 'createdTime', - direction: this.sort?.direction === 'asc' ? Direction.ASC : Direction.DESC - }; - const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder); - const typeFilters = this.activeTypeFilters.size > 0 ? Array.from(this.activeTypeFilters) : null; - this.iotHubApiService.getInstalledItems(pageLink, typeFilters, {ignoreLoading: true}).subscribe({ - next: (data) => { - this.dataSource = data.data; - this.totalElements = data.totalElements; - this.isLoading = false; - }, - error: () => { - this.dataSource = []; - this.totalElements = 0; - this.isLoading = false; - } - }); + get updatesChecked(): boolean { + return this.tableComponent?.updatesChecked || false; } } diff --git a/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts b/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts new file mode 100644 index 0000000000..7ed84f10e7 --- /dev/null +++ b/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts @@ -0,0 +1,174 @@ +/// +/// 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 { AfterViewInit, Directive, ElementRef, Input, NgZone, OnDestroy, Renderer2 } from '@angular/core'; + +@Directive({ + selector: '[tb-chip-overflow]', + standalone: false +}) +export class ChipOverflowDirective implements AfterViewInit, OnDestroy { + + @Input() chipSelector = '.tb-chip'; + @Input() gap = 8; + @Input() overflowTemplate = '+{n}'; + @Input() overflowClass = 'tb-overflow-chip'; + @Input() minChips = 1; + + private resizeObserver?: ResizeObserver; + private mutationObserver?: MutationObserver; + private overflowEl!: HTMLElement; + private timeoutId?: NodeJS.Timeout; + + constructor( + private host: ElementRef, + private renderer: Renderer2, + private zone: NgZone + ) {} + + ngAfterViewInit(): void { + this.prepareHost(); + this.createOverflowChip(); + this.zone.runOutsideAngular(() => { + requestAnimationFrame(() => { + this.reflow(); + this.observeResize(); + this.observeMutations(); + }); + }); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + this.mutationObserver?.disconnect(); + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + } + + private prepareHost() { + const el = this.host.nativeElement; + const styles: { [key: string]: string } = { + display: 'flex', + flexWrap: 'nowrap', + alignItems: 'center', + overflow: 'hidden', + gap: `${this.gap}px`, + whiteSpace: 'nowrap', + minWidth: '0' + }; + Object.entries(styles).forEach(([prop, value]) => + this.renderer.setStyle(el, prop, value) + ); + } + + private createOverflowChip() { + this.overflowEl = this.renderer.createElement('div') as HTMLElement; + this.renderer.setProperty(this.overflowEl, 'className', this.overflowClass); + this.renderer.setStyle(this.overflowEl, 'display', 'none'); + this.renderer.setStyle(this.overflowEl, 'userSelect', 'none'); + this.renderer.setStyle(this.overflowEl, 'pointerEvents', 'none'); + this.renderer.appendChild(this.host.nativeElement, this.overflowEl); + } + + private observeResize(): void { + this.resizeObserver = new ResizeObserver(() => { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(() => this.reflow()); + }); + this.resizeObserver.observe(this.host.nativeElement); + } + + private observeMutations(): void { + this.mutationObserver = new MutationObserver(mutations => { + const relevantMutations = mutations.some( + mutation => + mutation.type === 'childList' && + Array.from(mutation.addedNodes).some(node => node instanceof HTMLElement && node.matches(this.chipSelector)) || + Array.from(mutation.removedNodes).some(node => node instanceof HTMLElement && node.matches(this.chipSelector)) + ); + + if (relevantMutations) { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(() => this.reflow()); + } + }); + + this.mutationObserver.observe(this.host.nativeElement, { + childList: true, + }); + } + + private reflow() { + const container = this.host.nativeElement; + const chips = Array.from(container.querySelectorAll(this.chipSelector)) + .filter(el => el !== this.overflowEl); + + if (!chips.length) { + return; + } + + // 1. Show all chips to measure their natural widths + chips.forEach(chip => this.renderer.setStyle(chip, 'display', '')); + this.renderer.setStyle(this.overflowEl, 'display', 'none'); + + const chipWidths = chips.map(chip => Math.ceil(chip.offsetWidth)); + + // 2. Hide everything to measure the true available width + // without content inflating the table cell + chips.forEach(chip => this.renderer.setStyle(chip, 'display', 'none')); + + const availableWidth = container.clientWidth; + + if (!availableWidth) { + chips.forEach(chip => this.renderer.setStyle(chip, 'display', '')); + return; + } + + // 3. Measure overflow chip width + this.renderer.setProperty(this.overflowEl, 'textContent', this.overflowTemplate.replace('{n}', '9')); + this.renderer.setStyle(this.overflowEl, 'display', 'inline-flex'); + const minOverflowWidth = this.overflowEl.offsetWidth; + this.renderer.setStyle(this.overflowEl, 'display', 'none'); + + // 4. Determine which chips fit + let usedWidth = 0; + let hiddenCount = 0; + const minChips = Math.max(1, this.minChips); + + for (let i = 0; i < chips.length; i++) { + const chipWidth = chipWidths[i]; + const nextUsedWidth = usedWidth ? usedWidth + this.gap + chipWidth : chipWidth; + const withOverflow = nextUsedWidth + (nextUsedWidth ? this.gap : 0) + minOverflowWidth; + + if (i < minChips || withOverflow <= availableWidth) { + this.renderer.setStyle(chips[i], 'display', 'inline-flex'); + usedWidth = nextUsedWidth; + } else { + hiddenCount++; + } + } + + if (hiddenCount > 0) { + this.renderer.setProperty(this.overflowEl, 'textContent', this.overflowTemplate.replace('{n}', String(hiddenCount))); + this.renderer.setStyle(this.overflowEl, 'display', 'inline-flex'); + } + } +} diff --git a/ui-ngx/src/app/shared/directives/public-api.ts b/ui-ngx/src/app/shared/directives/public-api.ts index 98c68dfd28..1ff38116fc 100644 --- a/ui-ngx/src/app/shared/directives/public-api.ts +++ b/ui-ngx/src/app/shared/directives/public-api.ts @@ -17,3 +17,4 @@ export * from './truncate-with-tooltip.directive'; export * from './ellipsis-chip-list.directive'; export * from './context-menu.directive'; +export * from './chip-overflow.directive'; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index c3791c1332..37d9d56f81 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -68,6 +68,7 @@ import { EditorModule, TINYMCE_SCRIPT_SRC } from '@tinymce/tinymce-angular'; import { UserMenuComponent } from '@shared/components/user-menu.component'; import { TruncateWithTooltipDirective } from '@shared/directives/truncate-with-tooltip.directive'; import { ContextMenuDirective } from '@shared/directives/context-menu.directive'; +import { ChipOverflowDirective } from '@shared/directives/chip-overflow.directive'; import { NospacePipe } from '@shared/pipe/nospace.pipe'; import { TranslateModule } from '@ngx-translate/core'; import { TbCheckboxComponent } from '@shared/components/tb-checkbox.component'; @@ -386,6 +387,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MarkdownEditorComponent, TruncateWithTooltipDirective, ContextMenuDirective, + ChipOverflowDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -651,6 +653,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MarkdownEditorComponent, TruncateWithTooltipDirective, ContextMenuDirective, + ChipOverflowDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index a613c235f0..85221035a0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3756,6 +3756,7 @@ "sort-most-installed": "Most installed", "sort-newest": "Newest", "sort-name": "Name (A-Z)", + "type": "Type", "type-filter-label": "Type", "category": "Category", "use-case": "Use case", @@ -3774,6 +3775,7 @@ "no-items-found-text": "Try adjusting your search or filters.", "creator": "Creator", "manage": "Manage", + "details": "Details", "become-a-creator": "Become a creator", "back-to-iot-hub": "Back to IoT Hub", "verified-creator": "Verified Creator", @@ -3835,6 +3837,7 @@ "most-popular": "Most popular", "all": "All", "installed-items": "Installed Items", + "installed-items-for": "Installed Items for \"{{name}}\"", "search-installed-items": "Search installed items...", "item-name": "Name", "item-type": "Type", @@ -3848,6 +3851,7 @@ "installed-item-remove-error": "Failed to remove '{{name}}'", "open-installed-entity": "Open installed entity", "view-item-details": "View item details", + "review-device-instructions": "Review device instructions", "open-item-type": "Open {{type}}", "back-to-marketplace": "Back to Marketplace", "device-install-connection": "Connection: {{type}}",