Browse Source

feat(iot-hub): implement IoT Hub home-page widget with item cards

- Two-column widget showing top-3 solution templates and top-3 devices
  fetched by totalInstallCount DESC, with installed-state fetching
  (solution templates list + device counts)
- Section titles as .tb-title-link routerLinks to /iot-hub/solution-templates
  and /iot-hub/devices
- Item cards open detail dialog via IotHubActionsService; solution template
  cards show green check_circle badge in top-right when installed
- HomePageWidgetsModule now imports IotHubComponentsModule to resolve
  IotHubActionsService

Device preview styling:
- Switch item card .tb-iot-hub-card-preview to white bg + bottom border
  when item.type === DEVICE
- Switch detail dialog .dlg-preview to white bg + full border when DEVICE
pull/15508/head
Igor Kulikov 6 months ago
parent
commit
2d72c27850
  1. 2
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html
  2. 5
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss
  3. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  4. 5
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss
  5. 4
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts
  6. 56
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.html
  7. 164
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.scss
  8. 80
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.ts

2
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html

@ -80,7 +80,7 @@
} @else {
<!-- Big card (Widget / Dashboard / Solution Template) -->
<div class="tb-iot-hub-item-card" role="button" tabindex="0" (click)="onCardClick()" (keyup.enter)="onCardClick()">
<div class="tb-iot-hub-card-preview">
<div class="tb-iot-hub-card-preview" [class.tb-iot-hub-card-preview-device]="item.type === ItemType.DEVICE">
@if (getPreviewUrl(); as previewUrl) {
<img [src]="previewUrl" alt="" class="tb-iot-hub-preview-image">
} @else {

5
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss

@ -52,6 +52,11 @@
position: relative;
overflow: hidden;
padding: 12px;
&.tb-iot-hub-card-preview-device {
background: white;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
}
.tb-iot-hub-preview-image {

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

@ -93,7 +93,9 @@
} @else {
<!-- Widget/Dashboard/Solution: image + description side by side -->
<div class="flex gap-6">
<div class="dlg-preview" [class.dlg-preview-carousel]="item.type === ItemType.SOLUTION_TEMPLATE && carouselImages.length > 1">
<div class="dlg-preview"
[class.dlg-preview-carousel]="item.type === ItemType.SOLUTION_TEMPLATE && carouselImages.length > 1"
[class.dlg-preview-device]="item.type === ItemType.DEVICE">
@if (item.type === ItemType.SOLUTION_TEMPLATE && carouselImages.length > 1) {
<div class="dlg-hero-carousel">
<ngx-hm-carousel

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

@ -143,6 +143,11 @@
&.dlg-preview-carousel {
aspect-ratio: 1.5;
}
&.dlg-preview-device {
background: white;
border: 1px solid rgba(0, 0, 0, 0.12);
}
}
.dlg-preview-img {

4
ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts

@ -36,6 +36,7 @@ import {
RecentDashboardsWidgetComponent
} from '@home/components/widget/lib/home-page/recent-dashboards-widget.component';
import { IotHubWidgetComponent } from '@home/components/widget/lib/home-page/iot-hub-widget.component';
import { IotHubComponentsModule } from '@home/components/iot-hub/iot-hub-components.module';
@NgModule({
declarations:
@ -58,7 +59,8 @@ import { IotHubWidgetComponent } from '@home/components/widget/lib/home-page/iot
],
imports: [
CommonModule,
SharedModule
SharedModule,
IotHubComponentsModule
],
exports: [
ClusterInfoTableComponent,

56
ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.html

@ -15,12 +15,64 @@
limitations under the License.
-->
<div class="tb-card-content flex flex-col gap-2">
<div class="tb-card-content flex h-full flex-col">
@if (hasIotHubAccess) {
<div class="tb-card-header flex flex-col gap-2.5">
<div class="tb-card-header flex flex-col">
<div class="tb-card-title-text">{{ 'widgets.iot-hub.title-text' | translate }}&nbsp;<a routerLink="/iot-hub">IoT Hub</a></div>
<mat-divider></mat-divider>
</div>
<div class="tb-iot-hub-widget-body">
<div class="tb-iot-hub-widget-column">
<a class="tb-title-link" routerLink="/iot-hub/solution-templates">{{ 'item.type-solution-template-plural' | translate }}</a>
<div class="tb-iot-hub-widget-cards">
@for (item of solutionTemplates; track item.id) {
<div class="tb-iot-hub-widget-card" role="button" tabindex="0"
(click)="openItemDetail(item)"
(keyup.enter)="openItemDetail(item)">
<div class="tb-iot-hub-widget-card-image tb-iot-hub-widget-card-image-pattern">
@if (getItemImage(item); as imgUrl) {
<img [src]="imgUrl" alt="">
}
@if (isInstalled(item)) {
<mat-icon class="tb-iot-hub-widget-card-installed">check_circle</mat-icon>
}
</div>
<div class="tb-iot-hub-widget-card-label">
<span class="tb-iot-hub-widget-card-name" tbTruncateWithTooltip>{{ item.name }}</span>
<span class="tb-iot-hub-widget-card-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
</div>
</div>
}
</div>
</div>
<mat-divider vertical></mat-divider>
<div class="tb-iot-hub-widget-column">
<a class="tb-title-link" routerLink="/iot-hub/devices">{{ 'iot-hub.device-library' | translate }}</a>
<div class="tb-iot-hub-widget-cards">
@for (item of devices; track item.id) {
<div class="tb-iot-hub-widget-card" role="button" tabindex="0"
(click)="openItemDetail(item)"
(keyup.enter)="openItemDetail(item)">
<div class="tb-iot-hub-widget-card-image">
@if (getItemImage(item); as imgUrl) {
<img [src]="imgUrl" alt="">
}
</div>
<div class="tb-iot-hub-widget-card-label">
<span class="tb-iot-hub-widget-card-name" tbTruncateWithTooltip>{{ item.name }}</span>
<span class="tb-iot-hub-widget-card-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
</div>
</div>
}
</div>
</div>
</div>
} @else {
<div class="tb-no-data-available">
<div class="tb-no-data-bg"></div>

164
ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.scss

@ -17,12 +17,23 @@
:host {
.tb-card-header {
gap: 10px;
@media #{$mat-md-lg} {
gap: 8px;
@media #{$mat-lt-lg} {
gap: 4px;
}
}
.tb-card-title-text {
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
color: rgba(0, 0, 0, 0.76);
@media #{$mat-md-lg} {
font-size: 12px;
line-height: 16px;
}
a {
display: inline-block;
font-weight: 500;
@ -43,4 +54,157 @@
}
}
}
.tb-iot-hub-widget-body {
display: flex;
flex: 1 1 0;
min-height: 0;
gap: 0;
padding: 8px 0 0;
@media #{$mat-lt-md} {
flex-direction: column;
gap: 12px;
}
.mat-divider {
margin: 24px 16px;
@media #{$mat-lt-md} {
display: none;
}
@media #{$mat-md-lg} {
margin-left: 8px;
margin-right: 8px;
}
}
}
.tb-iot-hub-widget-column {
display: flex;
flex: 1 1 0;
min-width: 0;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.tb-iot-hub-widget-cards {
display: flex;
flex: 1 1 0;
min-height: 0;
gap: 12px;
@media #{$mat-md} {
gap: 8px;
}
}
.tb-iot-hub-widget-card {
display: flex;
flex: 1 1 0;
min-width: 0;
flex-direction: column;
gap: 4px;
cursor: pointer;
border: none;
background: none;
padding: 0;
text-align: left;
&:hover .tb-iot-hub-widget-card-image,
&:focus-visible .tb-iot-hub-widget-card-image {
border-color: rgba(0, 0, 0, 0.12);
box-shadow: 0 4px 10px 0 rgba(23, 33, 90, 0.08);
}
&:focus-visible {
outline: none;
}
}
.tb-iot-hub-widget-card-image {
position: relative;
display: flex;
flex: 1 1 0;
min-height: 0;
align-items: center;
justify-content: center;
padding: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 10px;
background: white;
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s;
@media #{$mat-md-lg} {
padding: 4px;
}
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
// Dot pattern used for solution template preview cards
.tb-iot-hub-widget-card-image-pattern {
background:
radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px) 0 0 / 15px 15px,
rgba(0, 0, 0, 0.04);
}
// Installed badge — green circle + check icon, top-right corner
.tb-iot-hub-widget-card-installed {
position: absolute;
top: 5px;
right: 5px;
width: 20px;
height: 20px;
font-size: 20px;
line-height: 20px;
color: #2e7d32;
background: white;
border-radius: 50%;
}
.tb-iot-hub-widget-card-label {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
}
.tb-iot-hub-widget-card-name {
flex: 1 1 0;
min-width: 0;
font-family: inherit;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: rgba(0, 0, 0, 0.76);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media #{$mat-md-lg} {
font-size: 11px;
}
}
.tb-iot-hub-widget-card-installs {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
letter-spacing: 0.4px;
color: rgba(0, 0, 0, 0.54);
mat-icon {
width: 12px;
height: 12px;
font-size: 12px;
line-height: 12px;
color: rgba(0, 0, 0, 0.54);
}
}
}

80
ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.ts

@ -15,10 +15,21 @@
///
import { Component, Input, OnInit } from '@angular/core';
import { forkJoin } from 'rxjs';
import { PageComponent } from '@shared/components/page.component';
import { Authority } from '@shared/models/authority.enum';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { WidgetContext } from '@home/models/widget-component.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils';
import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order';
const WIDGET_CARD_COUNT = 3;
@Component({
selector: 'tb-iot-hub-widget',
@ -37,7 +48,13 @@ export class IotHubWidgetComponent extends PageComponent implements OnInit {
hasIotHubAccess = true;
constructor() {
solutionTemplates: MpItemVersionView[] = [];
devices: MpItemVersionView[] = [];
installedSolutionTemplates: IotHubInstalledItem[] = [];
installedDeviceCounts: Record<string, number> = {};
constructor(private iotHubApiService: IotHubApiService,
private iotHubActions: IotHubActionsService) {
super();
}
@ -49,9 +66,66 @@ export class IotHubWidgetComponent extends PageComponent implements OnInit {
}
}
private load() {
openItemDetail(item: MpItemVersionView): void {
this.iotHubActions.openItemDetail(item, this.findInstalledSolutionTemplate(item), this.findInstalledDeviceCount(item))
.subscribe(result => {
if (result === 'installed' || result === 'deleted' || result === 'updated') {
this.reloadInstalled(item.type);
}
});
}
getItemImage(item: MpItemVersionView): string | null {
return resolveIotHubItemImageUrl(item, this.iotHubApiService);
}
}
isInstalled(item: MpItemVersionView): boolean {
return item.type === ItemType.SOLUTION_TEMPLATE && !!this.findInstalledSolutionTemplate(item);
}
private findInstalledSolutionTemplate(item: MpItemVersionView): IotHubInstalledItem | undefined {
return this.installedSolutionTemplates.find(i => i.itemId === item.itemId);
}
private findInstalledDeviceCount(item: MpItemVersionView): number {
return this.installedDeviceCounts[item.itemId] || 0;
}
private load(): void {
const config = { ignoreLoading: true };
const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC };
const buildQuery = (type: ItemType): MpItemVersionQuery =>
new MpItemVersionQuery(new PageLink(WIDGET_CARD_COUNT, 0, null, sortOrder), { type });
const installedPageLink = new PageLink(10000, 0);
forkJoin({
solutionTemplates: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.SOLUTION_TEMPLATE), config),
devices: this.iotHubApiService.getPublishedVersions(buildQuery(ItemType.DEVICE), config),
installedSolutionTemplates: this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, undefined, config),
installedDeviceCounts: this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config)
}).subscribe(result => {
this.solutionTemplates = result.solutionTemplates.data;
this.devices = result.devices.data;
this.installedSolutionTemplates = result.installedSolutionTemplates.data;
this.installedDeviceCounts = result.installedDeviceCounts;
this.ctx.detectChanges();
});
}
private reloadInstalled(type: ItemType): void {
const config = { ignoreLoading: true };
if (type === ItemType.SOLUTION_TEMPLATE) {
const installedPageLink = new PageLink(10000, 0);
this.iotHubApiService.getInstalledItems(installedPageLink, ItemType.SOLUTION_TEMPLATE, undefined, config).subscribe(data => {
this.installedSolutionTemplates = data.data;
this.ctx.detectChanges();
});
} else if (type === ItemType.DEVICE) {
this.iotHubApiService.getInstalledItemCounts(ItemType.DEVICE, config).subscribe(counts => {
this.installedDeviceCounts = counts;
this.ctx.detectChanges();
});
}
}
}

Loading…
Cancel
Save