Browse Source

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
pull/15508/head
Igor Kulikov 5 months ago
parent
commit
d859573bb6
  1. 5
      application/src/main/java/org/thingsboard/server/controller/IotHubController.java
  2. 2
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java
  3. 2
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java
  4. 4
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java
  5. 3
      dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java
  6. 3
      dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java
  7. 6
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  8. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  9. 4
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss
  10. 8
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts
  11. 10
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts
  12. 41
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html
  13. 96
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss
  14. 52
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.ts
  15. 162
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html
  16. 274
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss
  17. 395
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts
  18. 102
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  19. 65
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss
  20. 24
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts
  21. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts
  22. 8
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  23. 140
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html
  24. 224
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss
  25. 329
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts
  26. 174
      ui-ngx/src/app/shared/directives/chip-overflow.directive.ts
  27. 1
      ui-ngx/src/app/shared/directives/public-api.ts
  28. 3
      ui-ngx/src/app/shared/shared.module.ts
  29. 4
      ui-ngx/src/assets/locale/locale.constant-en_US.json

5
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 textSearch,
@RequestParam(required = false) String sortProperty, @RequestParam(required = false) String sortProperty,
@RequestParam(required = false) String sortOrder, @RequestParam(required = false) String sortOrder,
@RequestParam(required = false) List<String> itemTypes) throws ThingsboardException { @RequestParam(required = false) List<String> itemTypes,
@RequestParam(required = false) UUID itemId) throws ThingsboardException {
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); 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')") @PreAuthorize("hasAuthority('TENANT_ADMIN')")

2
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<IotHubInstalledItem> { public interface IotHubInstalledItemDao extends Dao<IotHubInstalledItem> {
PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, PageLink pageLink); PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, UUID itemId, PageLink pageLink);
List<UUID> findInstalledItemIdsByTenantId(TenantId tenantId); List<UUID> findInstalledItemIdsByTenantId(TenantId tenantId);

2
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); IotHubInstalledItem findById(TenantId tenantId, IotHubInstalledItemId id);
PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, PageLink pageLink); PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, UUID itemId, PageLink pageLink);
List<UUID> findInstalledItemIdsByTenantId(TenantId tenantId); List<UUID> findInstalledItemIdsByTenantId(TenantId tenantId);

4
dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java

@ -47,8 +47,8 @@ class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService {
} }
@Override @Override
public PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, PageLink pageLink) { public PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, UUID itemId, PageLink pageLink) {
return iotHubInstalledItemDao.findByTenantId(tenantId, itemTypes, pageLink); return iotHubInstalledItemDao.findByTenantId(tenantId, itemTypes, itemId, pageLink);
} }
@Override @Override

3
dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java

@ -36,12 +36,13 @@ interface IotHubInstalledItemRepository extends JpaRepository<IotHubInstalledIte
SELECT item FROM IotHubInstalledItemEntity item SELECT item FROM IotHubInstalledItemEntity item
WHERE item.tenantId = :tenantId WHERE item.tenantId = :tenantId
AND (:itemTypes IS NULL OR item.itemType IN :itemTypes) AND (:itemTypes IS NULL OR item.itemType IN :itemTypes)
AND (:itemId IS NULL OR item.itemId = :itemId)
AND (:textSearch IS NULL AND (:textSearch IS NULL
OR ilike(item.itemName, CONCAT('%', :textSearch, '%')) = true OR ilike(item.itemName, CONCAT('%', :textSearch, '%')) = true
OR ilike(item.itemType, CONCAT('%', :textSearch, '%')) = true OR ilike(item.itemType, CONCAT('%', :textSearch, '%')) = true
OR ilike(item.version, CONCAT('%', :textSearch, '%')) = true) OR ilike(item.version, CONCAT('%', :textSearch, '%')) = true)
""") """)
Page<IotHubInstalledItemEntity> findByTenantId(@Param("tenantId") UUID tenantId, @Param("itemTypes") List<String> itemTypes, @Param("textSearch") String textSearch, Pageable pageable); Page<IotHubInstalledItemEntity> findByTenantId(@Param("tenantId") UUID tenantId, @Param("itemTypes") List<String> itemTypes, @Param("itemId") UUID itemId, @Param("textSearch") String textSearch, Pageable pageable);
@Query(""" @Query("""
SELECT COUNT(item) FROM IotHubInstalledItemEntity item SELECT COUNT(item) FROM IotHubInstalledItemEntity item

3
dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java

@ -45,10 +45,11 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao<IotHubInstalledItemEntity
private final IotHubInstalledItemRepository repository; private final IotHubInstalledItemRepository repository;
@Override @Override
public PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, PageLink pageLink) { public PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, UUID itemId, PageLink pageLink) {
return DaoUtil.toPageData(repository.findByTenantId( return DaoUtil.toPageData(repository.findByTenantId(
tenantId.getId(), tenantId.getId(),
itemTypes == null || itemTypes.isEmpty() ? null : itemTypes, itemTypes == null || itemTypes.isEmpty() ? null : itemTypes,
itemId,
StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null),
toPageRequest(pageLink)) toPageRequest(pageLink))
); );

6
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<PageData<IotHubInstalledItem>> { public getInstalledItems(pageLink: PageLink, itemTypes?: string | string[],
itemId?: string, config?: IotHubRequestConfig): Observable<PageData<IotHubInstalledItem>> {
let query = pageLink.toQuery(); let query = pageLink.toQuery();
if (itemTypes) { if (itemTypes) {
const types = Array.isArray(itemTypes) ? itemTypes : [itemTypes]; const types = Array.isArray(itemTypes) ? itemTypes : [itemTypes];
types.forEach(t => query += `&itemTypes=${t}`); types.forEach(t => query += `&itemTypes=${t}`);
} }
if (itemId) {
query += `&itemId=${itemId}`;
}
return this.http.get<PageData<IotHubInstalledItem>>( return this.http.get<PageData<IotHubInstalledItem>>(
`/api/iot-hub/installedItems${query}`, `/api/iot-hub/installedItems${query}`,
{ params: this.buildParams(config) } { params: this.buildParams(config) }

2
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 = []; this.installedWidgets = [];
} }
const pageLink = new PageLink(10000, 0); 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; this.installedWidgets = data.data;
}); });
} }

4
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss

@ -40,6 +40,10 @@
height: 100%; height: 100%;
} }
.mat-mdc-tab-group {
height: 100%;
}
::ng-deep { ::ng-deep {
.mat-stepper-horizontal { .mat-stepper-horizontal {
height: 100%; height: 100%;

8
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; return;
} }
const pageLink = new PageLink(10000, 0); 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) => { next: (data) => {
this.installedWidgets = data.data; this.installedWidgets = data.data;
} }
@ -658,7 +658,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
return; return;
} }
const pageLink = new PageLink(10000, 0); 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) => { next: (data) => {
this.installedSolutionTemplates = data.data; this.installedSolutionTemplates = data.data;
} }
@ -669,11 +669,11 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
const config = {ignoreLoading: true}; const config = {ignoreLoading: true};
const pageLink = new PageLink(10000, 0); const pageLink = new PageLink(10000, 0);
if (this.activeType === ItemType.WIDGET) { 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; this.installedWidgets = data.data;
}); });
} else if (this.activeType === ItemType.SOLUTION_TEMPLATE) { } 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; this.installedSolutionTemplates = data.data;
}); });
} else { } else {

10
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 { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component';
import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component'; import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component';
import { TbIotHubSearchComponent } from './iot-hub-search.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'; import { IotHubActionsService } from './iot-hub-actions.service';
@NgModule({ @NgModule({
@ -38,7 +40,9 @@ import { IotHubActionsService } from './iot-hub-actions.service';
TbIotHubUpdateDialogComponent, TbIotHubUpdateDialogComponent,
TbIotHubDeleteDialogComponent, TbIotHubDeleteDialogComponent,
TbDeviceInstallDialogComponent, TbDeviceInstallDialogComponent,
TbIotHubSearchComponent TbIotHubSearchComponent,
TbIotHubInstalledItemsTableComponent,
TbIotHubInstalledItemsDialogComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -56,7 +60,9 @@ import { IotHubActionsService } from './iot-hub-actions.service';
TbIotHubUpdateDialogComponent, TbIotHubUpdateDialogComponent,
TbIotHubDeleteDialogComponent, TbIotHubDeleteDialogComponent,
TbDeviceInstallDialogComponent, TbDeviceInstallDialogComponent,
TbIotHubSearchComponent TbIotHubSearchComponent,
TbIotHubInstalledItemsTableComponent,
TbIotHubInstalledItemsDialogComponent
] ]
}) })
export class IotHubComponentsModule { } export class IotHubComponentsModule { }

41
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html

@ -0,0 +1,41 @@
<!--
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="tb-iot-hub-installed-items-dialog">
<div class="dlg-header">
<h2 class="dlg-title" tbTruncateWithTooltip>
{{ 'iot-hub.installed-items-for' | translate:{ name: item.name } }}
</h2>
<button mat-icon-button
class="dlg-close"
type="button"
(click)="close()">
<mat-icon>close</mat-icon>
</button>
</div>
<div mat-dialog-content>
<tb-iot-hub-installed-items-table [itemVersion]="item">
</tb-iot-hub-installed-items-table>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button
type="button"
(click)="close()">
{{ 'action.close' | translate }}
</button>
</div>
</div>

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

52
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<TbIotHubInstalledItemsDialogComponent> {
item: MpItemVersionView;
constructor(
protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<TbIotHubInstalledItemsDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: IotHubInstalledItemsDialogData
) {
super(store, router, dialogRef);
this.item = data.item;
}
close(): void {
this.dialogRef.close();
}
}

162
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html

@ -0,0 +1,162 @@
<!--
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="tb-installed-table-container">
<div class="tb-installed-table-wrapper">
<table mat-table [dataSource]="dataSource" matSort matSortActive="createdTime" matSortDirection="desc">
<!-- Name -->
<ng-container matColumnDef="itemName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.item-name' | translate }}</th>
<td mat-cell *matCellDef="let item">{{ item.itemName }}</td>
</ng-container>
<!-- Type -->
<ng-container matColumnDef="itemType">
<th mat-header-cell *matHeaderCellDef>{{ 'iot-hub.item-type' | translate }}</th>
<td mat-cell *matCellDef="let item">
<span class="tb-installed-type" [ngClass]="getItemTypeChipClass(item.itemType)">
<mat-icon class="tb-installed-type-icon">{{ getItemTypeIcon(item.itemType) }}</mat-icon>
{{ getItemTypeLabel(item.itemType) }}
</span>
</td>
</ng-container>
<!-- Version -->
<ng-container matColumnDef="version">
<th mat-header-cell *matHeaderCellDef>{{ 'iot-hub.item-version' | translate }}</th>
<td mat-cell *matCellDef="let item">{{ item.version }}</td>
</ng-container>
<!-- Installed date -->
<ng-container matColumnDef="createdTime">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.installed-date' | translate }}</th>
<td mat-cell *matCellDef="let item">{{ item.createdTime | date:'mediumDate' }}</td>
</ng-container>
<!-- Updates -->
<ng-container matColumnDef="updates">
<th mat-header-cell *matHeaderCellDef>@if (updatesChecked) { {{ 'iot-hub.updates' | translate }} }</th>
<td mat-cell *matCellDef="let item">
@if (updatesChecked) {
@if (getPublishedVersionInfo(item); as pubInfo) {
@if (pubInfo.publishedVersionId !== item.itemVersionId) {
<div class="tb-installed-update-row">
@if (!itemVersion) {
<button class="tb-installed-version-btn" (click)="viewUpdateDetails(pubInfo, item)">
<mat-icon>arrow_upward</mat-icon>
v{{ pubInfo.publishedVersion }} {{ 'iot-hub.available' | translate }}
</button>
}
<button mat-stroked-button color="warn" class="tb-installed-update-btn" (click)="updateItem(item, pubInfo)">
@if (itemVersion) {
{{ 'iot-hub.update-to-version' | translate:{ version: pubInfo.publishedVersion } }}
} @else {
{{ 'iot-hub.update' | translate }}
}
</button>
</div>
} @else {
<span class="tb-installed-up-to-date">
<mat-icon>check</mat-icon>
{{ 'iot-hub.up-to-date' | translate }}
</span>
}
} @else {
<span class="tb-installed-up-to-date">
<mat-icon>check</mat-icon>
{{ 'iot-hub.up-to-date' | translate }}
</span>
}
}
</td>
</ng-container>
<!-- Actions -->
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef>
<div [ngClass]="itemVersion ? 'gt-sm:!hidden' : 'gt-md:!hidden'" style="min-width: 48px;"></div>
<div [ngClass]="itemVersion ? 'lt-md:!hidden' : 'lt-lg:!hidden'" style="min-width: 144px;"></div>
</th>
<td mat-cell *matCellDef="let item">
<!-- Desktop: individual buttons -->
<div class="tb-installed-actions" [ngClass]="itemVersion ? 'lt-md:!hidden' : 'lt-lg:!hidden'">
@if (!itemVersion || item.itemType === 'DEVICE') {
<button mat-icon-button (click)="viewItemDetails(item)" matTooltip="{{ (item.itemType === 'DEVICE' ? 'iot-hub.review-device-instructions' : 'iot-hub.view-item-details') | translate }}">
<mat-icon>info_outline</mat-icon>
</button>
}
<button mat-icon-button (click)="openEntity(item)" matTooltip="{{ (item.itemType === 'SOLUTION_TEMPLATE' ? 'iot-hub.goto-main-dashboard' : 'iot-hub.open-item-type') | translate:{ type: getItemTypeLabel(item.itemType) } }}">
<mat-icon>open_in_new</mat-icon>
</button>
<button mat-icon-button (click)="deleteItem(item)" matTooltip="{{ 'iot-hub.remove' | translate }}">
<tb-icon>mdi:trash-can-outline</tb-icon>
</button>
</div>
<!-- Mobile: dropdown menu -->
<div [ngClass]="itemVersion ? 'gt-sm:!hidden' : 'gt-md:!hidden'">
<button mat-icon-button (click)="$event.stopPropagation()" [matMenuTriggerFor]="actionsMenu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #actionsMenu="matMenu" xPosition="before">
@if (!itemVersion || item.itemType === 'DEVICE') {
<button mat-menu-item (click)="viewItemDetails(item)">
<mat-icon>info_outline</mat-icon>
<span>{{ (item.itemType === 'DEVICE' ? 'iot-hub.review-device-instructions' : 'iot-hub.view-item-details') | translate }}</span>
</button>
}
<button mat-menu-item (click)="openEntity(item)">
<mat-icon>open_in_new</mat-icon>
<span>{{ (item.itemType === 'SOLUTION_TEMPLATE' ? 'iot-hub.goto-main-dashboard' : 'iot-hub.open-item-type') | translate:{ type: getItemTypeLabel(item.itemType) } }}</span>
</button>
<button mat-menu-item (click)="deleteItem(item)">
<tb-icon>mdi:trash-can-outline</tb-icon>
<span>{{ 'iot-hub.remove' | translate }}</span>
</button>
</mat-menu>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
@if (isLoading) {
<div class="tb-installed-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
@if (!isLoading && dataSource.length === 0) {
<div class="tb-installed-empty">
{{ 'iot-hub.no-installed-items' | translate }}
</div>
}
<!-- Paginator inside border -->
<mat-divider></mat-divider>
<mat-paginator
[length]="totalElements"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[10, 25, 50]"
[hidePageSize]="hidePageSize"
(page)="onPageChange($event)"
showFirstLastButtons>
</mat-paginator>
</div>

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

395
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<string, ItemPublishedVersionInfo>();
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<AppState>,
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;
}
});
}
}

102
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html

@ -35,10 +35,6 @@
<tb-icon class="dlg-subtitle-icon">download</tb-icon> <tb-icon class="dlg-subtitle-icon">download</tb-icon>
<span>{{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }}</span> <span>{{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }}</span>
</span> </span>
@if (getSubtypeLabel(); as subtypeLabel) {
<span class="dlg-dot"></span>
<span class="dlg-subtitle-group">{{ subtypeLabel }}</span>
}
<span class="dlg-dot"></span> <span class="dlg-dot"></span>
<span class="dlg-subtitle-group"> <span class="dlg-subtitle-group">
<mat-icon class="dlg-subtitle-icon">update</mat-icon> <mat-icon class="dlg-subtitle-icon">update</mat-icon>
@ -177,7 +173,7 @@
<!-- Categories & Use Cases / Device metadata --> <!-- Categories & Use Cases / Device metadata -->
<div class="dlg-meta-bar"> <div class="dlg-meta-bar">
<div class="dlg-divider"></div> <div class="dlg-divider"></div>
<div class="dlg-meta"> <div class="dlg-meta" [class.dlg-meta-4-col]="item.type === ItemType.SOLUTION_TEMPLATE || item.type === ItemType.DASHBOARD">
@if (showCreator) { @if (showCreator) {
<div class="dlg-meta-group"> <div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.creator' | translate }}</span> <span class="dlg-meta-label">{{ 'iot-hub.creator' | translate }}</span>
@ -196,19 +192,27 @@
</div> </div>
</div> </div>
} }
@if (getSubtypeLabel(); as subtypeLabel) {
<div class="dlg-meta-group dlg-meta-details-section">
<span class="dlg-meta-label">{{ 'iot-hub.type' | translate }}</span>
<div class="dlg-meta-chips">
<span class="dlg-chip dlg-chip-type dlg-chip-inline" tbTruncateWithTooltip>{{ subtypeLabel }}</span>
</div>
</div>
}
@if (item.type === ItemType.DEVICE) { @if (item.type === ItemType.DEVICE) {
@if (item.dataDescriptor?.hardwareType) { @if (item.dataDescriptor?.hardwareType) {
<div class="dlg-meta-group"> <div class="dlg-meta-group dlg-meta-details-section">
<span class="dlg-meta-label">{{ 'iot-hub.hardware-type' | translate }}</span> <span class="dlg-meta-label">{{ 'iot-hub.hardware-type' | translate }}</span>
<div class="dlg-meta-chips"> <div class="dlg-meta-chips">
<span class="dlg-chip dlg-chip-category">{{ item.dataDescriptor.hardwareType }}</span> <span class="dlg-chip dlg-chip-type dlg-chip-inline" tbTruncateWithTooltip>{{ item.dataDescriptor.hardwareType }}</span>
</div> </div>
</div> </div>
} }
@if (item.dataDescriptor?.connectivity?.length) { @if (item.dataDescriptor?.connectivity?.length) {
<div class="dlg-meta-group"> <div class="dlg-meta-group dlg-meta-details-section">
<span class="dlg-meta-label">{{ 'iot-hub.connectivity' | translate }}</span> <span class="dlg-meta-label">{{ 'iot-hub.connectivity' | translate }}</span>
<div class="dlg-meta-chips"> <div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-category" [gap]="4" [minChips]="2">
@for (conn of item.dataDescriptor.connectivity; track conn) { @for (conn of item.dataDescriptor.connectivity; track conn) {
<span class="dlg-chip dlg-chip-category">{{ conn }}</span> <span class="dlg-chip dlg-chip-category">{{ conn }}</span>
} }
@ -217,9 +221,9 @@
} }
} @else { } @else {
@if (item.categories?.length) { @if (item.categories?.length) {
<div class="dlg-meta-group"> <div class="dlg-meta-group dlg-meta-details-section">
<span class="dlg-meta-label">{{ 'iot-hub.category' | translate }}</span> <span class="dlg-meta-label">{{ 'iot-hub.category' | translate }}</span>
<div class="dlg-meta-chips"> <div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-category" [gap]="4" [minChips]="2">
@for (cat of item.categories; track cat) { @for (cat of item.categories; track cat) {
<span class="dlg-chip dlg-chip-category">{{ cat }}</span> <span class="dlg-chip dlg-chip-category">{{ cat }}</span>
} }
@ -228,9 +232,9 @@
} }
} }
@if (item.useCases?.length) { @if (item.useCases?.length) {
<div class="dlg-meta-group"> <div class="dlg-meta-group dlg-meta-details-section">
<span class="dlg-meta-label">{{ 'iot-hub.use-cases' | translate }}</span> <span class="dlg-meta-label">{{ 'iot-hub.use-cases' | translate }}</span>
<div class="dlg-meta-chips"> <div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-usecase" [gap]="4">
@for (uc of item.useCases; track uc) { @for (uc of item.useCases; track uc) {
<span class="dlg-chip dlg-chip-usecase">{{ uc }}</span> <span class="dlg-chip dlg-chip-usecase">{{ uc }}</span>
} }
@ -270,6 +274,21 @@
</div> </div>
</div> </div>
} }
@if (hasDetails()) {
<div class="dlg-meta-group dlg-meta-details">
<span class="dlg-meta-label">{{ 'iot-hub.details' | translate }}</span>
<button mat-icon-button class="dlg-manage-icon-btn dlg-meta-details-btn"
tb-popover #detailsPopover="tbPopover"
[tbPopoverContent]="detailsPopoverContent"
tbPopoverTrigger="click"
tbPopoverPlacement="bottom"
[tbPopoverOverlayStyle]="{width: '400px'}"
[matTooltip]="'iot-hub.details' | translate"
matTooltipPosition="above">
<tb-icon class="tb-mat-24">mdi:dots-horizontal-circle-outline</tb-icon>
</button>
</div>
}
</div> </div>
<div class="dlg-divider"></div> <div class="dlg-divider"></div>
</div> </div>
@ -329,3 +348,60 @@
} }
</div> </div>
</div> </div>
<ng-template #detailsPopoverContent>
<div class="dlg-details-popover">
<span class="dlg-details-popover-title">{{ 'iot-hub.details' | translate }}</span>
<div class="dlg-details-popover-body">
@if (getSubtypeLabel(); as subtypeLabel) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.type' | translate }}</span>
<div class="dlg-meta-chips">
<span class="dlg-chip dlg-chip-type">{{ subtypeLabel }}</span>
</div>
</div>
}
@if (item.type === ItemType.DEVICE) {
@if (item.dataDescriptor?.hardwareType) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.hardware-type' | translate }}</span>
<div class="dlg-meta-chips">
<span class="dlg-chip dlg-chip-type">{{ item.dataDescriptor.hardwareType }}</span>
</div>
</div>
}
@if (item.dataDescriptor?.connectivity?.length) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.connectivity' | translate }}</span>
<div class="dlg-meta-chips">
@for (conn of item.dataDescriptor.connectivity; track conn) {
<span class="dlg-chip dlg-chip-category">{{ conn }}</span>
}
</div>
</div>
}
} @else {
@if (item.categories?.length) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.category' | translate }}</span>
<div class="dlg-meta-chips">
@for (cat of item.categories; track cat) {
<span class="dlg-chip dlg-chip-category">{{ cat }}</span>
}
</div>
</div>
}
}
@if (item.useCases?.length) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.use-cases' | translate }}</span>
<div class="dlg-meta-chips">
@for (uc of item.useCases; track uc) {
<span class="dlg-chip dlg-chip-usecase">{{ uc }}</span>
}
</div>
</div>
}
</div>
</div>
</ng-template>

65
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss

@ -284,11 +284,57 @@
background: white; 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 { .dlg-meta {
display: flex; display: grid;
gap: 24px; gap: 24px;
padding: 16px 24px 12px; 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 { .dlg-meta-group {
@ -355,6 +401,9 @@
line-height: 16px; line-height: 16px;
letter-spacing: 0.25px; letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.54); color: rgba(0, 0, 0, 0.54);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.dlg-meta-chips { .dlg-meta-chips {
@ -400,12 +449,24 @@
white-space: nowrap; 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 // Category chip — Design: bg rgba(16,108,200,0.06), color #106cc8
.dlg-chip-category { .dlg-chip-category {
background: rgba(16, 108, 200, 0.06); background: rgba(16, 108, 200, 0.06);
color: #106cc8; 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 // Use case chip — Design: bg rgba(25,128,56,0.06), color #198038
.dlg-chip-usecase { .dlg-chip-usecase {
background: rgba(25, 128, 56, 0.06); background: rgba(25, 128, 56, 0.06);

24
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 { 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 { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component';
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; 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'; export type IotHubItemDetailDialogMode = 'default' | 'add';
@ -374,7 +378,25 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
} }
openInstalledItemsDialog(): void { openInstalledItemsDialog(): void {
// TODO: implement installed items dialog this.dialog.open(TbIotHubInstalledItemsDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
disableClose: true,
data: { item: this.item } as IotHubInstalledItemsDialogData
});
}
hasDetails(): boolean {
if (this.getSubtypeLabel()) {
return true;
}
if (this.item.useCases?.length) {
return true;
}
if (this.item.type === ItemType.DEVICE) {
return !!this.item.dataDescriptor?.hardwareType || !!this.item.dataDescriptor?.connectivity?.length;
}
return !!this.item.categories?.length;
} }
goToPrevSlide(): void { goToPrevSlide(): void {

4
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts

@ -304,8 +304,8 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
const config = { ignoreLoading: true }; const config = { ignoreLoading: true };
const pageLink = new PageLink(10000, 0); const pageLink = new PageLink(10000, 0);
forkJoin({ forkJoin({
widgets: this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, config), widgets: this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, undefined, config),
solutionTemplates: this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, config), solutionTemplates: this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, undefined, config),
deviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config), deviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config),
dashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config), dashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config),
calcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config), calcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config),

8
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts

@ -327,11 +327,11 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
this.installedItemsCount = count; this.installedItemsCount = count;
}); });
if (type === ItemType.WIDGET) { if (type === 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; this.installedWidgets = data.data;
}); });
} else if (type === ItemType.SOLUTION_TEMPLATE) { } 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; this.installedSolutionTemplates = data.data;
}); });
} else if (type === ItemType.DEVICE) { } 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), calcFields: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.CALCULATED_FIELD, this.compactCardCount), config),
ruleChains: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.RULE_CHAIN, this.compactCardCount), config), ruleChains: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.RULE_CHAIN, this.compactCardCount), config),
devices: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DEVICE, this.bigCardCount), config), devices: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DEVICE, this.bigCardCount), config),
installedWidgets: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.WIDGET, config), installedWidgets: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.WIDGET, undefined, config),
installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, config), installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, undefined, config),
installedDeviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config), installedDeviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config),
installedDashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config), installedDashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config),
installedCalcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config), installedCalcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config),

140
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html

@ -78,140 +78,8 @@
</div> </div>
</div> </div>
<!-- Table + Paginator wrapped in border --> <tb-iot-hub-installed-items-table
<div class="tb-installed-table-container"> [textSearch]="appliedTextSearch"
<div class="tb-installed-table-wrapper"> [typeFilters]="typeFilters">
<table mat-table [dataSource]="dataSource" matSort matSortActive="createdTime" matSortDirection="desc"> </tb-iot-hub-installed-items-table>
<!-- Name -->
<ng-container matColumnDef="itemName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.item-name' | translate }}</th>
<td mat-cell *matCellDef="let item">{{ item.itemName }}</td>
</ng-container>
<!-- Type -->
<ng-container matColumnDef="itemType">
<th mat-header-cell *matHeaderCellDef>{{ 'iot-hub.item-type' | translate }}</th>
<td mat-cell *matCellDef="let item">
<span class="tb-installed-type" [ngClass]="getItemTypeChipClass(item.itemType)">
<mat-icon class="tb-installed-type-icon">{{ getItemTypeIcon(item.itemType) }}</mat-icon>
{{ getItemTypeLabel(item.itemType) }}
</span>
</td>
</ng-container>
<!-- Version -->
<ng-container matColumnDef="version">
<th mat-header-cell *matHeaderCellDef>{{ 'iot-hub.item-version' | translate }}</th>
<td mat-cell *matCellDef="let item">{{ item.version }}</td>
</ng-container>
<!-- Installed date -->
<ng-container matColumnDef="createdTime">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.installed-date' | translate }}</th>
<td mat-cell *matCellDef="let item">{{ item.createdTime | date:'mediumDate' }}</td>
</ng-container>
<!-- Updates -->
<ng-container matColumnDef="updates">
<th mat-header-cell *matHeaderCellDef>@if (updatesChecked) { {{ 'iot-hub.updates' | translate }} }</th>
<td mat-cell *matCellDef="let item">
@if (updatesChecked) {
@if (getPublishedVersionInfo(item); as pubInfo) {
@if (pubInfo.publishedVersionId !== item.itemVersionId) {
<div class="tb-installed-update-row">
<button class="tb-installed-version-btn" (click)="viewUpdateDetails(pubInfo, item)">
<mat-icon>arrow_upward</mat-icon>
v{{ pubInfo.publishedVersion }} {{ 'iot-hub.available' | translate }}
</button>
<button mat-stroked-button color="warn" class="tb-installed-update-btn" (click)="updateItem(item, pubInfo)">
{{ 'iot-hub.update' | translate }}
</button>
</div>
} @else {
<span class="tb-installed-up-to-date">
<mat-icon>check</mat-icon>
{{ 'iot-hub.up-to-date' | translate }}
</span>
}
} @else {
<span class="tb-installed-up-to-date">
<mat-icon>check</mat-icon>
{{ 'iot-hub.up-to-date' | translate }}
</span>
}
}
</td>
</ng-container>
<!-- Actions -->
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef>
<div class="gt-md:!hidden" style="min-width: 48px;"></div>
<div class="lt-lg:!hidden" style="min-width: 144px;"></div>
</th>
<td mat-cell *matCellDef="let item">
<!-- Desktop: individual buttons -->
<div class="tb-installed-actions lt-lg:!hidden">
<button mat-icon-button (click)="viewItemDetails(item)" matTooltip="{{ 'iot-hub.view-item-details' | translate }}">
<mat-icon>info_outline</mat-icon>
</button>
<button mat-icon-button (click)="openEntity(item)" matTooltip="{{ (item.itemType === 'SOLUTION_TEMPLATE' ? 'iot-hub.goto-main-dashboard' : 'iot-hub.open-item-type') | translate:{ type: getItemTypeLabel(item.itemType) } }}">
<mat-icon>open_in_new</mat-icon>
</button>
<button mat-icon-button (click)="deleteItem(item)" matTooltip="{{ 'iot-hub.remove' | translate }}">
<tb-icon>mdi:trash-can-outline</tb-icon>
</button>
</div>
<!-- Mobile: dropdown menu -->
<div class="gt-md:!hidden">
<button mat-icon-button (click)="$event.stopPropagation()" [matMenuTriggerFor]="actionsMenu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #actionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="viewItemDetails(item)">
<mat-icon>info_outline</mat-icon>
<span>{{ 'iot-hub.view-item-details' | translate }}</span>
</button>
<button mat-menu-item (click)="openEntity(item)">
<mat-icon>open_in_new</mat-icon>
<span>{{ (item.itemType === 'SOLUTION_TEMPLATE' ? 'iot-hub.goto-main-dashboard' : 'iot-hub.open-item-type') | translate:{ type: getItemTypeLabel(item.itemType) } }}</span>
</button>
<button mat-menu-item (click)="deleteItem(item)">
<tb-icon>mdi:trash-can-outline</tb-icon>
<span>{{ 'iot-hub.remove' | translate }}</span>
</button>
</mat-menu>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
@if (isLoading) {
<div class="tb-installed-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
@if (!isLoading && dataSource.length === 0) {
<div class="tb-installed-empty">
{{ 'iot-hub.no-installed-items' | translate }}
</div>
}
<!-- Paginator inside border -->
<mat-divider></mat-divider>
<mat-paginator
[length]="totalElements"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[10, 25, 50]"
[hidePageSize]="hidePageSize"
(page)="onPageChange($event)"
showFirstLastButtons>
</mat-paginator>
</div>
</div> </div>

224
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 // Responsive
@media #{$mat-lt-lg} { @media #{$mat-lt-lg} {
.tb-installed-toolbar { .tb-installed-toolbar {
@ -392,12 +175,6 @@
width: 100%; width: 100%;
order: 9; order: 9;
} }
.mat-column-actions {
width: 36px;
min-width: 36px;
max-width: 36px;
}
} }
@media #{$mat-lt-md} { @media #{$mat-lt-md} {
@ -421,5 +198,4 @@
.tb-installed-search { .tb-installed-search {
width: 200px; width: 200px;
} }
} }

329
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts

@ -14,30 +14,13 @@
/// limitations under the License. /// 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 { 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 { 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 { Subject } from 'rxjs';
import { debounceTime, switchMap } from 'rxjs/operators'; import { debounceTime, takeUntil } 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 { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { EntityType } from '@shared/models/entity-type.models'; import { TbIotHubInstalledItemsTableComponent } from '@home/components/iot-hub/iot-hub-installed-items-table.component';
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';
@Component({ @Component({
selector: 'tb-iot-hub-installed-items', 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', templateUrl: './iot-hub-installed-items.component.html',
styleUrls: ['./iot-hub-installed-items.component.scss'] 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 = ''; textSearch = '';
appliedTextSearch = '';
typeFilters: string[] = [];
publishedVersionMap = new Map<string, ItemPublishedVersionInfo>();
updatesChecked = false;
isCheckingUpdates = false;
// Type filter
filterOpen = false; filterOpen = false;
activeTypeFilters = new Set<string>(); activeTypeFilters = new Set<string>();
allItemTypes: string[] = ['WIDGET', 'DASHBOARD', 'SOLUTION_TEMPLATE', 'CALCULATED_FIELD', 'RULE_CHAIN', 'DEVICE']; allItemTypes: string[] = ['WIDGET', 'DASHBOARD', 'SOLUTION_TEMPLATE', 'CALCULATED_FIELD', 'RULE_CHAIN', 'DEVICE'];
private searchSubject = new Subject<string>(); @ViewChild(TbIotHubInstalledItemsTableComponent) tableComponent: TbIotHubInstalledItemsTableComponent;
@ViewChild(MatSort, {static: true}) sort: MatSort; private searchSubject = new Subject<string>();
@ViewChild(MatPaginator, {static: true}) paginator: MatPaginator; private destroy$ = new Subject<void>();
constructor( constructor(
private iotHubApiService: IotHubApiService,
private dialogService: DialogService,
private translate: TranslateService, private translate: TranslateService,
private store: Store<AppState>,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, 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 { ngOnInit(): void {
const itemType = this.route.snapshot.queryParamMap.get('itemType'); const itemType = this.route.snapshot.queryParamMap.get('itemType');
if (itemType && this.allItemTypes.includes(itemType)) { if (itemType && this.allItemTypes.includes(itemType)) {
this.activeTypeFilters.add(itemType); this.activeTypeFilters.add(itemType);
this.typeFilters = Array.from(this.activeTypeFilters);
} }
this.searchSubject.pipe( this.searchSubject.pipe(
debounceTime(300) debounceTime(300),
).subscribe(() => { takeUntil(this.destroy$)
this.pageIndex = 0; ).subscribe(value => {
this.paginator.pageIndex = 0; this.appliedTextSearch = value;
this.loadData();
}); });
this.loadData();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.widgetResize$?.disconnect(); this.destroy$.next();
} this.destroy$.complete();
ngAfterViewInit(): void {
this.sort.sortChange.subscribe(() => {
this.pageIndex = 0;
this.paginator.pageIndex = 0;
this.loadData();
});
} }
navigateToMarketplace(): void { navigateToMarketplace(): void {
@ -132,12 +77,6 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit, O
this.searchSubject.next(value); this.searchSubject.next(value);
} }
onPageChange(event: any): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.loadData();
}
toggleFilter(): void { toggleFilter(): void {
this.filterOpen = !this.filterOpen; this.filterOpen = !this.filterOpen;
} }
@ -152,253 +91,37 @@ export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit, O
} else { } else {
this.activeTypeFilters.add(type); this.activeTypeFilters.add(type);
} }
this.pageIndex = 0; this.typeFilters = Array.from(this.activeTypeFilters);
this.loadData();
} }
removeTypeFilter(type: string): void { removeTypeFilter(type: string): void {
this.activeTypeFilters.delete(type); this.activeTypeFilters.delete(type);
this.pageIndex = 0; this.typeFilters = Array.from(this.activeTypeFilters);
this.loadData();
} }
clearAllFilters(): void { clearAllFilters(): void {
this.activeTypeFilters.clear(); this.activeTypeFilters.clear();
this.pageIndex = 0; this.typeFilters = [];
this.loadData();
} }
hasActiveFilters(): boolean { hasActiveFilters(): boolean {
return this.activeTypeFilters.size > 0; 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 { getItemTypeLabel(itemType: string): string {
const key = itemTypeTranslations.get(itemType as ItemType); const key = itemTypeTranslations.get(itemType as ItemType);
return key ? this.translate.instant(key) : 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 { checkForUpdates(): void {
this.isCheckingUpdates = true; this.tableComponent?.checkForUpdates();
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 { get isCheckingUpdates(): boolean {
this.iotHubApiService.getVersionInfo(publishedInfo.publishedVersionId, {ignoreLoading: true}).subscribe(versionView => { return this.tableComponent?.isCheckingUpdates || false;
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 { get updatesChecked(): boolean {
const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { return this.tableComponent?.updatesChecked || false;
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;
}
});
} }
} }

174
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<HTMLElement>,
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<HTMLElement>(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');
}
}
}

1
ui-ngx/src/app/shared/directives/public-api.ts

@ -17,3 +17,4 @@
export * from './truncate-with-tooltip.directive'; export * from './truncate-with-tooltip.directive';
export * from './ellipsis-chip-list.directive'; export * from './ellipsis-chip-list.directive';
export * from './context-menu.directive'; export * from './context-menu.directive';
export * from './chip-overflow.directive';

3
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 { UserMenuComponent } from '@shared/components/user-menu.component';
import { TruncateWithTooltipDirective } from '@shared/directives/truncate-with-tooltip.directive'; import { TruncateWithTooltipDirective } from '@shared/directives/truncate-with-tooltip.directive';
import { ContextMenuDirective } from '@shared/directives/context-menu.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 { NospacePipe } from '@shared/pipe/nospace.pipe';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { TbCheckboxComponent } from '@shared/components/tb-checkbox.component'; import { TbCheckboxComponent } from '@shared/components/tb-checkbox.component';
@ -386,6 +387,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
MarkdownEditorComponent, MarkdownEditorComponent,
TruncateWithTooltipDirective, TruncateWithTooltipDirective,
ContextMenuDirective, ContextMenuDirective,
ChipOverflowDirective,
NospacePipe, NospacePipe,
MillisecondsToTimeStringPipe, MillisecondsToTimeStringPipe,
EnumToArrayPipe, EnumToArrayPipe,
@ -651,6 +653,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
MarkdownEditorComponent, MarkdownEditorComponent,
TruncateWithTooltipDirective, TruncateWithTooltipDirective,
ContextMenuDirective, ContextMenuDirective,
ChipOverflowDirective,
NospacePipe, NospacePipe,
MillisecondsToTimeStringPipe, MillisecondsToTimeStringPipe,
EnumToArrayPipe, EnumToArrayPipe,

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

@ -3756,6 +3756,7 @@
"sort-most-installed": "Most installed", "sort-most-installed": "Most installed",
"sort-newest": "Newest", "sort-newest": "Newest",
"sort-name": "Name (A-Z)", "sort-name": "Name (A-Z)",
"type": "Type",
"type-filter-label": "Type", "type-filter-label": "Type",
"category": "Category", "category": "Category",
"use-case": "Use case", "use-case": "Use case",
@ -3774,6 +3775,7 @@
"no-items-found-text": "Try adjusting your search or filters.", "no-items-found-text": "Try adjusting your search or filters.",
"creator": "Creator", "creator": "Creator",
"manage": "Manage", "manage": "Manage",
"details": "Details",
"become-a-creator": "Become a creator", "become-a-creator": "Become a creator",
"back-to-iot-hub": "Back to IoT Hub", "back-to-iot-hub": "Back to IoT Hub",
"verified-creator": "Verified Creator", "verified-creator": "Verified Creator",
@ -3835,6 +3837,7 @@
"most-popular": "Most popular", "most-popular": "Most popular",
"all": "All", "all": "All",
"installed-items": "Installed Items", "installed-items": "Installed Items",
"installed-items-for": "Installed Items for \"{{name}}\"",
"search-installed-items": "Search installed items...", "search-installed-items": "Search installed items...",
"item-name": "Name", "item-name": "Name",
"item-type": "Type", "item-type": "Type",
@ -3848,6 +3851,7 @@
"installed-item-remove-error": "Failed to remove '{{name}}'", "installed-item-remove-error": "Failed to remove '{{name}}'",
"open-installed-entity": "Open installed entity", "open-installed-entity": "Open installed entity",
"view-item-details": "View item details", "view-item-details": "View item details",
"review-device-instructions": "Review device instructions",
"open-item-type": "Open {{type}}", "open-item-type": "Open {{type}}",
"back-to-marketplace": "Back to Marketplace", "back-to-marketplace": "Back to Marketplace",
"device-install-connection": "Connection: {{type}}", "device-install-connection": "Connection: {{type}}",

Loading…
Cancel
Save