Browse Source

feat(iot-hub): redesign item detail dialog, add installed item counts API

Redesign item detail dialog: move install/add/update/open buttons to footer,
move creator to sticky meta bar with avatar and verified badge, add manage
section with solution instructions, remove, and installed items count buttons,
update subtitle with grouped icon+text pairs matching design.

Add getInstalledItemCounts API (backend + frontend) returning item counts
by itemId per type. Replace installedDevices list with count-based tracking
in home, browse, and search components. Add installedItemsCount to item card
and detail dialog data flow.
pull/15508/head
Igor Kulikov 2 months ago
parent
commit
8e8efed94c
  1. 8
      application/src/main/java/org/thingsboard/server/controller/IotHubController.java
  2. 3
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java
  3. 3
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java
  4. 6
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java
  5. 8
      dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java
  6. 12
      dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java
  7. 7
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  8. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts
  9. 1
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html
  10. 40
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts
  11. 1
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts
  12. 240
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  13. 136
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss
  14. 22
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts
  15. 1
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html
  16. 31
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts
  17. 6
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html
  18. 64
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  19. 4
      ui-ngx/src/assets/locale/locale.constant-en_US.json

8
application/src/main/java/org/thingsboard/server/controller/IotHubController.java

@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.thingsboard.server.common.data.id.IotHubInstalledItemId;
import org.thingsboard.server.dao.device.DeviceConnectivityService;
@ -117,6 +118,13 @@ public class IotHubController extends BaseController {
return iotHubInstalledItemService.findInstalledItemIdsByTenantId(getTenantId());
}
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@GetMapping("/installedItems/counts")
@ResponseBody
public Map<UUID, Long> getInstalledItemCounts(@RequestParam String itemType) throws ThingsboardException {
return iotHubInstalledItemService.findInstalledItemCounts(getTenantId(), itemType);
}
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@DeleteMapping("/installedItems/{installedItemId}")
@ResponseBody

3
dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java

@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.Dao;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public interface IotHubInstalledItemDao extends Dao<IotHubInstalledItem> {
@ -32,6 +33,8 @@ public interface IotHubInstalledItemDao extends Dao<IotHubInstalledItem> {
long countByTenantId(TenantId tenantId, String itemType);
Map<UUID, Long> findInstalledItemCounts(TenantId tenantId, String itemType);
void deleteByTenantId(TenantId tenantId);
}

3
dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java

@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public interface IotHubInstalledItemService {
@ -36,6 +37,8 @@ public interface IotHubInstalledItemService {
long countByTenantId(TenantId tenantId, String itemType);
Map<UUID, Long> findInstalledItemCounts(TenantId tenantId, String itemType);
void deleteById(TenantId tenantId, IotHubInstalledItemId id);
void deleteByTenantId(TenantId tenantId);

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

@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
@ -60,6 +61,11 @@ class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService {
return iotHubInstalledItemDao.countByTenantId(tenantId, itemType);
}
@Override
public Map<UUID, Long> findInstalledItemCounts(TenantId tenantId, String itemType) {
return iotHubInstalledItemDao.findInstalledItemCounts(tenantId, itemType);
}
@Override
public void deleteById(TenantId tenantId, IotHubInstalledItemId id) {
iotHubInstalledItemDao.removeById(tenantId, id.getId());

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

@ -50,6 +50,14 @@ interface IotHubInstalledItemRepository extends JpaRepository<IotHubInstalledIte
""")
long countByTenantId(@Param("tenantId") UUID tenantId, @Param("itemType") String itemType);
@Query("""
SELECT item.itemId, COUNT(item) FROM IotHubInstalledItemEntity item
WHERE item.tenantId = :tenantId
AND item.itemType = :itemType
GROUP BY item.itemId
""")
List<Object[]> findInstalledItemCounts(@Param("tenantId") UUID tenantId, @Param("itemType") String itemType);
@Transactional
@Modifying
@Query(value = "DELETE FROM iot_hub_installed_item WHERE tenant_id = :tenantId", nativeQuery = true)

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

@ -32,7 +32,9 @@ import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
import org.thingsboard.server.dao.util.SqlDao;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@SqlDao
@ -62,6 +64,16 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao<IotHubInstalledItemEntity
return repository.countByTenantId(tenantId.getId(), itemType);
}
@Override
public Map<UUID, Long> findInstalledItemCounts(TenantId tenantId, String itemType) {
List<Object[]> results = repository.findInstalledItemCounts(tenantId.getId(), itemType);
Map<UUID, Long> counts = new HashMap<>();
for (Object[] row : results) {
counts.put((UUID) row[0], (Long) row[1]);
}
return counts;
}
@Override
public void deleteByTenantId(TenantId tenantId) {
repository.deleteByTenantId(tenantId.getId());

7
ui-ngx/src/app/core/http/iot-hub-api.service.ts

@ -167,6 +167,13 @@ export class IotHubApiService {
);
}
public getInstalledItemCounts(itemType: string, config?: IotHubRequestConfig): Observable<Record<string, number>> {
return this.http.get<Record<string, number>>(
`/api/iot-hub/installedItems/counts?itemType=${itemType}`,
{ params: this.buildParams(config) }
);
}
public getInstalledItems(pageLink: PageLink, itemTypes?: string | string[], config?: IotHubRequestConfig): Observable<PageData<IotHubInstalledItem>> {
let query = pageLink.toQuery();
if (itemTypes) {

4
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts

@ -38,12 +38,12 @@ export class IotHubActionsService {
private iotHubApiService: IotHubApiService
) {}
openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem,
openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number,
mode?: IotHubItemDetailDialogMode, showCreator?: boolean): Observable<any> {
return this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
data: { item, installedItem, mode, showCreator } as IotHubItemDetailDialogData
data: { item, installedItem, installedItemsCount, mode, showCreator } as IotHubItemDetailDialogData
}).afterClosed();
}

1
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html

@ -461,6 +461,7 @@
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledItem(item)"
[installedItemsCount]="getInstalledItemsCount(item)"
[showTypeChip]="false"
[showSubtype]="!fixedSubType"
[showCreator]="!creatorId"

40
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts

@ -71,12 +71,12 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
// trigger type-specific loading since our ngOnInit already ran
if (wasInit) {
this.loadFilterInfo();
if (value === ItemType.DEVICE) {
this.loadInstalledDevices();
} else if (value === ItemType.WIDGET) {
if (value === ItemType.WIDGET) {
this.loadInstalledWidgets();
} else if (value === ItemType.SOLUTION_TEMPLATE) {
this.loadInstalledSolutionTemplates();
} else {
this.loadInstalledItemCounts();
}
}
}
@ -128,7 +128,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
installedWidgets: IotHubInstalledItem[] = null;
installedSolutionTemplates: IotHubInstalledItem[] = null;
installedDevices: IotHubInstalledItem[] = null;
installedItemCounts: Record<string, number> = {};
private searchSubject = new Subject<string>();
private destroy$ = new Subject<void>();
@ -165,8 +165,8 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadInstalledWidgets();
} else if (this.activeType === ItemType.SOLUTION_TEMPLATE) {
this.loadInstalledSolutionTemplates();
} else if (this.activeType === ItemType.DEVICE) {
this.loadInstalledDevices();
} else {
this.loadInstalledItemCounts();
}
this.loadItems();
}
@ -206,8 +206,8 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadInstalledWidgets();
} else if (type === ItemType.SOLUTION_TEMPLATE) {
this.loadInstalledSolutionTemplates();
} else if (type === ItemType.DEVICE) {
this.loadInstalledDevices();
} else {
this.loadInstalledItemCounts();
}
this.loadItems();
}
@ -555,14 +555,15 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
if (this.activeType === ItemType.SOLUTION_TEMPLATE && this.installedSolutionTemplates) {
return this.installedSolutionTemplates.find(i => i.itemId === item.itemId);
}
if (this.activeType === ItemType.DEVICE && this.installedDevices) {
return this.installedDevices.find(i => i.itemId === item.itemId);
}
return undefined;
}
getInstalledItemsCount(item: MpItemVersionView): number {
return this.installedItemCounts[item.itemId] || 0;
}
openItemDetail(item: MpItemVersionView): void {
this.iotHubActions.openItemDetail(item, this.getInstalledItem(item), this.mode).subscribe(result => {
this.iotHubActions.openItemDetail(item, this.getInstalledItem(item), this.getInstalledItemsCount(item), this.mode).subscribe(result => {
if (result?.action === 'add') {
this.addItem.emit(result.item);
} else if (result === 'installed' || result === 'updated' || result === 'deleted') {
@ -644,11 +645,10 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
});
}
private loadInstalledDevices(): void {
const pageLink = new PageLink(10000, 0);
this.iotHubApiService.getInstalledItems(pageLink, 'DEVICE', {ignoreLoading: true, ignoreErrors: true}).subscribe({
next: (data) => {
this.installedDevices = data.data;
private loadInstalledItemCounts(): void {
this.iotHubApiService.getInstalledItemCounts(this.activeType, {ignoreLoading: true}).subscribe({
next: (counts) => {
this.installedItemCounts = counts;
}
});
}
@ -676,9 +676,9 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, config).subscribe(data => {
this.installedSolutionTemplates = data.data;
});
} else if (this.activeType === ItemType.DEVICE) {
this.iotHubApiService.getInstalledItems(pageLink, 'DEVICE', config).subscribe(data => {
this.installedDevices = data.data;
} else {
this.iotHubApiService.getInstalledItemCounts(this.activeType, config).subscribe(counts => {
this.installedItemCounts = counts;
});
}
}

1
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts

@ -35,6 +35,7 @@ export class TbIotHubItemCardComponent {
@Input() item: MpItemVersionView;
@Input() installedItem: IotHubInstalledItem;
@Input() installedItemsCount = 0;
@Input() showCreator = true;
@Input() showTypeChip = true;
@Input() showSubtype = false;

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

@ -26,26 +26,50 @@
<div class="dlg-header-content">
<span class="dlg-title">{{ item.name }}</span>
<div class="dlg-subtitle">
<tb-icon class="dlg-subtitle-icon">{{ getTypeIcon() }}</tb-icon>
<span>{{ typeTranslations.get(item.type) | translate }}</span>
<span class="dlg-subtitle-group">
<tb-icon class="dlg-subtitle-icon">{{ getTypeIcon() }}</tb-icon>
<span>{{ typeTranslations.get(item.type) | translate }}</span>
</span>
<span class="dlg-dot"></span>
<tb-icon class="dlg-subtitle-icon">download</tb-icon>
<span>{{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }}</span>
<span class="dlg-subtitle-group">
<tb-icon class="dlg-subtitle-icon">download</tb-icon>
<span>{{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }}</span>
</span>
@if (getSubtypeLabel(); as subtypeLabel) {
<span class="dlg-dot"></span>
<span>{{ subtypeLabel }}</span>
<span class="dlg-subtitle-group">{{ subtypeLabel }}</span>
}
<span class="dlg-dot"></span>
<span>v {{ item.version }}</span>
<span class="dlg-subtitle-group">
<mat-icon class="dlg-subtitle-icon">update</mat-icon>
<span>v {{ item.version }}</span>
</span>
@if (item.publishedTime) {
<span class="dlg-dot"></span>
<span>{{ item.publishedTime | date:'mediumDate' }}</span>
<span class="dlg-subtitle-group">
<mat-icon class="dlg-subtitle-icon">today</mat-icon>
<span>{{ item.publishedTime | date:'mediumDate' }}</span>
</span>
}
</div>
</div>
<button mat-icon-button class="dlg-close" (click)="close()" type="button">
<tb-icon>close</tb-icon>
</button>
<div class="dlg-header-actions">
@if (isInstalled()) {
@if (hasUpdate()) {
<button class="dlg-update-btn" (click)="updateItem()">
{{ 'iot-hub.update-to-version' | translate:{ version: item.version } }}
</button>
} @else {
<span class="dlg-installed-badge">
<mat-icon>check</mat-icon>
{{ 'iot-hub.installed' | translate }} v{{ installedItem.version }}
</span>
}
}
<button mat-icon-button class="dlg-close" (click)="close()" type="button">
<tb-icon>close</tb-icon>
</button>
</div>
</div>
<!-- Scrollable content -->
@ -56,57 +80,18 @@
<!-- CF/RC/Device: description + author (no image) -->
<div class="dlg-info dlg-info-compact">
<div class="dlg-info-text">
@if (isInstalled()) {
<div class="dlg-info-status-row">
<span class="dlg-installed-badge">
<mat-icon>check</mat-icon>
{{ 'iot-hub.installed' | translate }} v{{ installedItem.version }}
</span>
<a class="dlg-info-link" (click)="openEntityDetails()">
<mat-icon>open_in_new</mat-icon>
{{ 'iot-hub.open-item-type' | translate:{ type: getTypeLabel() } }}
</a>
</div>
}
@if (item.description) {
<div class="dlg-description">
<tb-markdown [data]="item.description"></tb-markdown>
</div>
}
@if (showCreator) {
<div class="dlg-author" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && navigateToCreator()">
<tb-icon>person</tb-icon>
<span>{{ item.creatorDisplayName }}</span>
</div>
}
</div>
<div class="dlg-info-actions">
@if (mode === 'add') {
@if (!isInstalled()) {
<button mat-flat-button color="primary" (click)="addItem()">
{{ 'action.add' | translate }}
</button>
}
} @else {
@if (!isInstalled()) {
<button mat-flat-button color="primary" (click)="install()">
{{ 'iot-hub.install' | translate }}
</button>
} @else if (hasUpdate()) {
<button mat-flat-button color="primary" (click)="updateItem()">
{{ 'iot-hub.update' | translate }}
</button>
}
@if (isInstalled()) {
@if (item.type !== ItemType.WIDGET && item.type !== ItemType.SOLUTION_TEMPLATE) {
<button mat-stroked-button (click)="install()">
<mat-icon>add</mat-icon>
{{ 'iot-hub.install-one-more' | translate }}
</button>
}
<button mat-stroked-button color="warn" (click)="deleteItem()">
<mat-icon>delete</mat-icon>
{{ 'iot-hub.remove' | translate }}
@if (mode !== 'add' && isInstalled()) {
@if (item.type !== ItemType.WIDGET && item.type !== ItemType.SOLUTION_TEMPLATE) {
<button mat-stroked-button (click)="install()">
<mat-icon>add</mat-icon>
{{ 'iot-hub.install-one-more' | translate }}
</button>
}
}
@ -167,80 +152,19 @@
</div>
<div class="dlg-info">
<div class="dlg-info-text">
<!-- Installed status + links -->
@if (isInstalled()) {
@if (item.type === ItemType.SOLUTION_TEMPLATE) {
<!-- Solution template: badge on own row, links on separate row -->
<span class="dlg-installed-badge">
<mat-icon>check</mat-icon>
{{ 'iot-hub.installed' | translate }} v{{ installedItem.version }}
</span>
<div class="dlg-info-status-row">
@if (isSameVersion()) {
<a class="dlg-info-link" (click)="openSolutionInstructions()">
<mat-icon>info_outline</mat-icon>
{{ 'iot-hub.solution-instructions' | translate }}
</a>
}
<a class="dlg-info-link" (click)="openEntityDetails()">
<mat-icon>open_in_new</mat-icon>
{{ 'iot-hub.goto-main-dashboard' | translate }}
</a>
</div>
} @else {
<!-- Other types: badge + link on same row -->
<div class="dlg-info-status-row">
<span class="dlg-installed-badge">
<mat-icon>check</mat-icon>
{{ 'iot-hub.installed' | translate }} v{{ installedItem.version }}
</span>
<a class="dlg-info-link" (click)="openEntityDetails()">
<mat-icon>open_in_new</mat-icon>
{{ 'iot-hub.open-item-type' | translate:{ type: getTypeLabel() } }}
</a>
</div>
}
}
@if (item.description) {
<div class="dlg-description">
<tb-markdown [data]="item.description"></tb-markdown>
</div>
}
@if (showCreator) {
<div class="dlg-author" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && navigateToCreator()">
<tb-icon>person</tb-icon>
<span>{{ item.creatorDisplayName }}</span>
</div>
}
</div>
<!-- Action buttons -->
<div class="dlg-info-actions">
@if (mode === 'add') {
@if (!isInstalled()) {
<button mat-flat-button color="primary" (click)="addItem()">
{{ 'action.add' | translate }}
</button>
}
} @else {
@if (!isInstalled()) {
<button mat-flat-button color="primary" (click)="install()">
{{ 'iot-hub.install' | translate }}
</button>
} @else if (hasUpdate()) {
<button mat-flat-button color="primary" (click)="updateItem()">
{{ 'iot-hub.update' | translate }}
</button>
}
@if (isInstalled()) {
@if (item.type !== ItemType.WIDGET && item.type !== ItemType.SOLUTION_TEMPLATE) {
<button mat-stroked-button (click)="install()">
<mat-icon>add</mat-icon>
{{ 'iot-hub.install-one-more' | translate }}
</button>
}
<button mat-stroked-button color="warn" (click)="deleteItem()">
<tb-icon matButtonIcon class="tb-mat-24">mdi:trash-can-outline</tb-icon>
{{ 'iot-hub.remove' | translate }}
@if (mode !== 'add' && isInstalled()) {
@if (item.type !== ItemType.WIDGET && item.type !== ItemType.SOLUTION_TEMPLATE) {
<button mat-stroked-button (click)="install()">
<mat-icon>add</mat-icon>
{{ 'iot-hub.install-one-more' | translate }}
</button>
}
}
@ -254,6 +178,24 @@
<div class="dlg-meta-bar">
<div class="dlg-divider"></div>
<div class="dlg-meta">
@if (showCreator) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.creator' | translate }}</span>
<div class="dlg-meta-creator" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && navigateToCreator()">
<div class="dlg-meta-creator-avatar">
@if (getCreatorAvatarUrl(); as avatarUrl) {
<img [src]="avatarUrl" alt="">
} @else {
<mat-icon>person</mat-icon>
}
@if (item.creatorVerified) {
<mat-icon class="dlg-meta-creator-verified">verified</mat-icon>
}
</div>
<span class="dlg-meta-creator-name">{{ item.creatorDisplayName }}</span>
</div>
</div>
}
@if (item.type === ItemType.DEVICE) {
@if (item.dataDescriptor?.hardwareType) {
<div class="dlg-meta-group">
@ -295,6 +237,39 @@
</div>
</div>
}
@if ((isInstalled() || installedItemsCount > 0) && mode !== 'add') {
<div class="dlg-meta-group dlg-meta-manage">
<span class="dlg-meta-label">{{ 'iot-hub.manage' | translate }}</span>
<div class="dlg-meta-manage-actions">
@if (item.type === ItemType.SOLUTION_TEMPLATE) {
<button mat-icon-button class="dlg-manage-icon-btn"
[matTooltip]="'iot-hub.solution-instructions' | translate"
matTooltipPosition="above"
(click)="openSolutionInstructions()">
<mat-icon>info_outline</mat-icon>
</button>
<button mat-icon-button class="dlg-manage-icon-btn"
[matTooltip]="'iot-hub.remove' | translate"
matTooltipPosition="above"
(click)="deleteItem()">
<tb-icon class="tb-mat-24">mdi:trash-can-outline</tb-icon>
</button>
} @else {
@if (installedItemsCount > 0) {
<button mat-stroked-button color="primary" (click)="openInstalledItemsDialog()">
<mat-icon>inventory_2</mat-icon>
{{ 'iot-hub.installed-items' | translate }}: {{ installedItemsCount }}
</button>
} @else {
<button mat-stroked-button class="dlg-manage-remove-btn" (click)="deleteItem()">
<tb-icon matButtonIcon class="tb-mat-24">mdi:trash-can-outline</tb-icon>
{{ 'iot-hub.remove' | translate }}
</button>
}
}
</div>
</div>
}
</div>
<div class="dlg-divider"></div>
</div>
@ -327,5 +302,30 @@
<div class="dlg-footer">
<span class="dlg-footer-spacer"></span>
<button mat-button (click)="close()">{{ 'action.close' | translate }}</button>
@if (mode === 'add' && !isInstalled()) {
<button mat-flat-button color="primary" (click)="addItem()">
{{ 'action.add' | translate }}
</button>
}
@if (!isInstalled() && mode !== 'add') {
@if (item.type === ItemType.DEVICE) {
<button mat-flat-button color="primary" (click)="installDevice()">
{{ 'iot-hub.connect-device' | translate }}
</button>
} @else {
<button mat-flat-button color="primary" (click)="install()">
{{ 'iot-hub.install' | translate }}
</button>
}
}
@if (isInstalled() && mode !== 'add') {
<button mat-flat-button color="primary" (click)="openEntityDetails()">
@if (item.type === ItemType.SOLUTION_TEMPLATE) {
{{ 'iot-hub.goto-main-dashboard' | translate }}
} @else {
{{ 'iot-hub.open-item-type' | translate:{ type: getTypeLabel() } }}
}
</button>
}
</div>
</div>

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

@ -51,6 +51,7 @@
gap: 16px;
padding: 16px 12px 16px 24px;
flex-shrink: 0;
}
// Header icon for CF/RC Design: 64px, rounded-8, colored bg, white icon 36px
@ -87,11 +88,11 @@
color: rgba(0, 0, 0, 0.87);
}
// Subtitle row Design: 13px Regular, rgba(0,0,0,0.54), gap-12 between groups, gap-4 within
// Subtitle row Design: 13px Regular, rgba(0,0,0,0.54), gap-12 between groups
.dlg-subtitle {
display: flex;
align-items: center;
gap: 4px;
gap: 12px;
font-size: 13px;
line-height: 16px;
letter-spacing: 0.4px;
@ -99,20 +100,33 @@
flex-wrap: wrap;
}
.dlg-subtitle-group {
display: inline-flex;
align-items: center;
gap: 4px;
}
.dlg-subtitle-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: rgba(0, 0, 0, 0.54);
}
// Dot separator Design: 4px circle, rgba(0,0,0,0.54)
// Dot separator Design: 3px circle, rgba(0,0,0,0.38)
.dlg-dot {
width: 4px;
height: 4px;
border-radius: 2px;
background: rgba(0, 0, 0, 0.54);
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.38);
flex-shrink: 0;
}
.dlg-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin: 0 4px;
}
.dlg-close {
@ -274,13 +288,64 @@
.dlg-meta {
display: flex;
gap: 24px;
padding: 16px 24px;
padding: 16px 24px 12px;
}
.dlg-meta-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.dlg-meta-creator {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
&.clickable {
cursor: pointer;
}
}
.dlg-meta-creator-avatar {
position: relative;
width: 32px;
height: 32px;
flex-shrink: 0;
img {
width: 32px;
height: 32px;
border-radius: 9999px;
object-fit: cover;
}
mat-icon:not(.dlg-meta-creator-verified) {
font-size: 32px;
width: 32px;
height: 32px;
color: rgba(0, 0, 0, 0.38);
}
.dlg-meta-creator-verified {
position: absolute;
top: -6px;
left: 18px;
font-size: 20px;
width: 20px;
height: 20px;
color: #00695c;
}
}
.dlg-meta-creator-name {
font-size: 12px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.76);
white-space: nowrap;
}
// Meta label Design: 12px Medium, rgba(0,0,0,0.54), tracking 0.25
@ -295,7 +360,30 @@
.dlg-meta-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 4px;
padding: 8px 0;
}
.dlg-meta-manage {
margin-left: auto;
flex-shrink: 0;
}
.dlg-meta-manage-actions {
display: flex;
align-items: center;
}
.dlg-manage-icon-btn.mat-mdc-icon-button {
width: 40px;
height: 40px;
padding: 8px;
--mdc-icon-button-state-layer-size: 40px;
color: rgba(0, 0, 0, 0.54);
}
.dlg-manage-remove-btn {
color: rgba(0, 0, 0, 0.54);
}
// Chip Design: 12px Medium, h-24, p-4 px-8, rounded-4
@ -550,7 +638,7 @@
.dlg-installed-badge {
display: inline-flex;
align-items: center;
align-self: flex-start;
flex-shrink: 0;
gap: 4px;
padding: 4px 8px;
border-radius: 16px;
@ -568,6 +656,29 @@
}
}
// Update button Design: border #ff5722, text #ff5722, rounded-4, px-16 py-6
.dlg-update-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 6px 16px;
border: 1px solid #ff5722;
border-radius: 4px;
background: transparent;
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.25px;
color: #ff5722;
white-space: nowrap;
cursor: pointer;
&:hover {
background: rgba(255, 87, 34, 0.04);
}
}
// Open details / Solution instructions link Design: 12px Medium, $tb-primary-color
.dlg-info-link {
display: inline-flex;
@ -615,10 +726,11 @@
}
}
// Footer only Close button
// Footer
.dlg-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-top: 1px solid rgba(0, 0, 0, 0.12);
flex-shrink: 0;

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

@ -39,6 +39,7 @@ export type IotHubItemDetailDialogMode = 'default' | 'add';
export interface IotHubItemDetailDialogData {
item: MpItemVersionView;
installedItem?: IotHubInstalledItem;
installedItemsCount?: number;
mode?: IotHubItemDetailDialogMode;
showCreator?: boolean;
}
@ -58,6 +59,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
typeTranslations = itemTypeTranslations;
readmeContent: string = '';
installedItem?: IotHubInstalledItem;
installedItemsCount = 0;
carouselImages: string[] = [];
carouselIndex = 0;
@ -75,6 +77,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
this.mode = data.mode || 'default';
this.showCreator = data.showCreator !== false;
this.installedItem = data.installedItem;
this.installedItemsCount = data.installedItemsCount || 0;
this.buildCarouselImages();
this.loadReadme();
}
@ -87,6 +90,10 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
return this.item.image ? this.iotHubApiService.resolveResourceUrl(this.item.image) : null;
}
getCreatorAvatarUrl(): string | null {
return this.item.creatorAvatarUrl ? this.iotHubApiService.resolveResourceUrl(this.item.creatorAvatarUrl) : null;
}
getTypeChipClass(): string {
switch (this.item.type) {
case ItemType.WIDGET: return 'type-widget';
@ -232,10 +239,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
return this.installedItem != null;
}
isSameVersion(): boolean {
return this.installedItem != null
&& this.installedItem.itemVersionId === this.item.id;
}
hasUpdate(): boolean {
return this.installedItem != null
@ -243,10 +247,6 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
install(): void {
if (this.item.type === ItemType.DEVICE) {
this.installDevice();
return;
}
const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, {
panelClass: ['tb-dialog'],
data: {
@ -260,7 +260,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
});
}
private installDevice(): void {
installDevice(): void {
const versionId = this.item.id as string;
this.iotHubApiService.getVersionFileData(versionId, { ignoreLoading: true }).subscribe({
next: async (blob: Blob) => {
@ -373,6 +373,10 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
this.dialogRef.close({ action: 'add', item: this.item });
}
openInstalledItemsDialog(): void {
// TODO: implement installed items dialog
}
goToPrevSlide(): void {
this.carouselIndex = (this.carouselIndex - 1 + this.carouselImages.length) % this.carouselImages.length;
}

1
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html

@ -126,6 +126,7 @@
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledItem(item)"
[installedItemsCount]="getInstalledItemsCount(item)"
[showTypeChip]="false"
[showSubtype]="false"
[showCreator]="showCreator"

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

@ -75,6 +75,10 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
installedWidgets: IotHubInstalledItem[] = [];
installedSolutionTemplates: IotHubInstalledItem[] = [];
installedDeviceCounts: Record<string, number> = {};
installedDashboardCounts: Record<string, number> = {};
installedCalcFieldCounts: Record<string, number> = {};
installedRuleChainCounts: Record<string, number> = {};
private searchSubject = new Subject<string>();
private searchSubscription: Subscription;
@ -205,9 +209,24 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
}
}
getInstalledItemsCount(item: MpItemVersionView): number {
switch (item.type) {
case ItemType.DEVICE:
return this.installedDeviceCounts[item.itemId] || 0;
case ItemType.DASHBOARD:
return this.installedDashboardCounts[item.itemId] || 0;
case ItemType.CALCULATED_FIELD:
return this.installedCalcFieldCounts[item.itemId] || 0;
case ItemType.RULE_CHAIN:
return this.installedRuleChainCounts[item.itemId] || 0;
default:
return 0;
}
}
// Dialogs
openItemDetail(item: MpItemVersionView): void {
this.iotHubActions.openItemDetail(item, this.getInstalledItem(item), undefined, this.showCreator).subscribe(result => {
this.iotHubActions.openItemDetail(item, this.getInstalledItem(item), this.getInstalledItemsCount(item), undefined, this.showCreator).subscribe(result => {
if (result === 'installed' || result === 'deleted' || result === 'updated') {
this.reloadInstalledItems();
}
@ -286,10 +305,18 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
const pageLink = new PageLink(10000, 0);
forkJoin({
widgets: this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, config),
solutionTemplates: this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, config)
solutionTemplates: this.iotHubApiService.getInstalledItems(pageLink, ItemType.SOLUTION_TEMPLATE, config),
deviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config),
dashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config),
calcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config),
ruleChainCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.RULE_CHAIN, config)
}).subscribe(results => {
this.installedWidgets = results.widgets.data;
this.installedSolutionTemplates = results.solutionTemplates.data;
this.installedDeviceCounts = results.deviceCounts;
this.installedDashboardCounts = results.dashboardCounts;
this.installedCalcFieldCounts = results.calcFieldCounts;
this.installedRuleChainCounts = results.ruleChainCounts;
});
}

6
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html

@ -275,14 +275,12 @@
@for (item of popularDevices; track item.id) {
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledDevice(item)"
[installedItemsCount]="findInstalledItemsCount(item)"
[showTypeChip]="false"
[showCreator]="true"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)">
(installClick)="installItem($event)">
</tb-iot-hub-item-card>
}
</div>

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

@ -128,7 +128,10 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
installedWidgets: IotHubInstalledItem[] = [];
installedSolutionTemplates: IotHubInstalledItem[] = [];
installedDevices: IotHubInstalledItem[] = [];
installedDeviceCounts: Record<string, number> = {};
installedDashboardCounts: Record<string, number> = {};
installedCalcFieldCounts: Record<string, number> = {};
installedRuleChainCounts: Record<string, number> = {};
installedItemsCount = 0;
isLoading = true;
@ -310,7 +313,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}
openItemDetail(item: MpItemVersionView): void {
this.iotHubActions.openItemDetail(item, this.findInstalledItem(item)).subscribe(result => {
this.iotHubActions.openItemDetail(item, this.findInstalledItem(item), this.findInstalledItemsCount(item)).subscribe(result => {
if (result === 'installed' || result === 'deleted') {
this.reloadInstalledItems(item.type);
}
@ -332,8 +335,20 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
this.installedSolutionTemplates = data.data;
});
} else if (type === ItemType.DEVICE) {
this.iotHubApiService.getInstalledItems(pageLink, ItemType.DEVICE, config).subscribe(data => {
this.installedDevices = data.data;
this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config).subscribe(counts => {
this.installedDeviceCounts = counts;
});
} else if (type === ItemType.DASHBOARD) {
this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config).subscribe(counts => {
this.installedDashboardCounts = counts;
});
} else if (type === ItemType.CALCULATED_FIELD) {
this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config).subscribe(counts => {
this.installedCalcFieldCounts = counts;
});
} else if (type === ItemType.RULE_CHAIN) {
this.iotHubApiService.getInstalledItemCounts(ItemType.RULE_CHAIN, config).subscribe(counts => {
this.installedRuleChainCounts = counts;
});
}
}
@ -344,13 +359,26 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
return this.installedWidgets.find(i => i.itemId === item.itemId);
case ItemType.SOLUTION_TEMPLATE:
return this.installedSolutionTemplates.find(i => i.itemId === item.itemId);
case ItemType.DEVICE:
return this.installedDevices.find(i => i.itemId === item.itemId);
default:
return undefined;
}
}
findInstalledItemsCount(item: MpItemVersionView): number {
switch (item.type) {
case ItemType.DEVICE:
return this.installedDeviceCounts[item.itemId] || 0;
case ItemType.DASHBOARD:
return this.installedDashboardCounts[item.itemId] || 0;
case ItemType.CALCULATED_FIELD:
return this.installedCalcFieldCounts[item.itemId] || 0;
case ItemType.RULE_CHAIN:
return this.installedRuleChainCounts[item.itemId] || 0;
default:
return 0;
}
}
installItem(item: MpItemVersionView): void {
this.iotHubActions.installItem(item).subscribe(result => {
if (result === 'installed') {
@ -381,10 +409,6 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
return this.installedSolutionTemplates.find(i => i.itemId === item.itemId);
}
getInstalledDevice(item: MpItemVersionView): IotHubInstalledItem | undefined {
return this.installedDevices.find(i => i.itemId === item.itemId);
}
deleteInstalledItem(item: MpItemVersionView): void {
const installedItem = this.findInstalledItem(item);
if (!installedItem) { return; }
@ -394,8 +418,14 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
this.installedWidgets = this.installedWidgets.filter(i => i.id.id !== installedItem.id.id);
} else if (item.type === ItemType.SOLUTION_TEMPLATE) {
this.installedSolutionTemplates = this.installedSolutionTemplates.filter(i => i.id.id !== installedItem.id.id);
} else if (item.type === ItemType.DEVICE) {
this.installedDevices = this.installedDevices.filter(i => i.id.id !== installedItem.id.id);
} else if (item.type === ItemType.DEVICE && this.installedDeviceCounts[item.itemId]) {
this.installedDeviceCounts[item.itemId] = Math.max(0, this.installedDeviceCounts[item.itemId] - 1);
} else if (item.type === ItemType.DASHBOARD && this.installedDashboardCounts[item.itemId]) {
this.installedDashboardCounts[item.itemId] = Math.max(0, this.installedDashboardCounts[item.itemId] - 1);
} else if (item.type === ItemType.CALCULATED_FIELD && this.installedCalcFieldCounts[item.itemId]) {
this.installedCalcFieldCounts[item.itemId] = Math.max(0, this.installedCalcFieldCounts[item.itemId] - 1);
} else if (item.type === ItemType.RULE_CHAIN && this.installedRuleChainCounts[item.itemId]) {
this.installedRuleChainCounts[item.itemId] = Math.max(0, this.installedRuleChainCounts[item.itemId] - 1);
}
});
}
@ -455,7 +485,10 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
devices: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DEVICE, this.bigCardCount), config),
installedWidgets: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.WIDGET, config),
installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, config),
installedDevices: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.DEVICE, config),
installedDeviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config),
installedDashboardCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DASHBOARD, config),
installedCalcFieldCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.CALCULATED_FIELD, config),
installedRuleChainCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.RULE_CHAIN, config),
installedCount: this.iotHubApiService.getInstalledItemsCount(null, config)
}).subscribe({
next: (results) => {
@ -467,7 +500,10 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
this.popularDevices = results.devices.data;
this.installedWidgets = results.installedWidgets.data;
this.installedSolutionTemplates = results.installedSolutionTemplates.data;
this.installedDevices = results.installedDevices.data;
this.installedDeviceCounts = results.installedDeviceCounts;
this.installedDashboardCounts = results.installedDashboardCounts;
this.installedCalcFieldCounts = results.installedCalcFieldCounts;
this.installedRuleChainCounts = results.installedRuleChainCounts;
this.installedItemsCount = results.installedCount;
this.isLoading = false;
},

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

@ -3772,6 +3772,8 @@
"retry": "Retry",
"no-items-found": "No items found",
"no-items-found-text": "Try adjusting your search or filters.",
"creator": "Creator",
"manage": "Manage",
"become-a-creator": "Become a creator",
"back-to-iot-hub": "Back to IoT Hub",
"verified-creator": "Verified Creator",
@ -3799,6 +3801,7 @@
"install-type": "Type: {{type}}",
"install-creator": "Creator: {{creator}}",
"install": "Install",
"connect-device": "Connect device",
"install-one-more": "Install one more",
"installing": "Installing...",
"installed": "Installed",
@ -3815,6 +3818,7 @@
"updates": "Updates",
"up-to-date": "Up to date",
"update": "Update",
"update-to-version": "Update to v {{ version }}",
"update-item-title": "Update Item",
"update-confirm": "Update '{{name}}' to version {{version}}?",
"update-confirm-title": "Update '{{name}}'?",

Loading…
Cancel
Save