Browse Source

Merge branch 'feature/iot-hub' into feature/lts-4.3-iot-hub

pull/15540/head
Igor Kulikov 4 months ago
parent
commit
57a1812718
  1. 22
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  2. 153
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html
  3. 15
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss
  4. 18
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts
  5. 2
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html
  6. 5
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss
  7. 3
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  8. 5
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss
  9. 177
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html
  10. 42
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss
  11. 48
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts
  12. 2
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.html
  13. 9
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.scss
  14. 19
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html
  15. 31
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss
  16. 39
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts
  17. 24
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html
  18. 22
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss
  19. 37
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  20. 1
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.scss
  21. 2
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts
  22. 8
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.scss
  23. 23
      ui-ngx/src/assets/home/no-service.svg
  24. 3
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  25. 25
      ui-ngx/src/form.scss

22
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts

@ -26,7 +26,7 @@ import {
widgetType,
WidgetTypeInfo
} from '@shared/models/widget.models';
import { debounceTime, distinctUntilChanged, map, skip, switchMap } from 'rxjs/operators';
import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, forkJoin, of } from 'rxjs';
import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order';
@ -780,21 +780,17 @@ export class DashboardWidgetSelectComponent {
}
private fetchInstalledWidgetVersions() {
const itemIds = (this.installedWidgets || []).map(i => i.itemId);
if (itemIds.length === 0) {
const versionIds = (this.installedWidgets || [])
.map(i => i.itemVersionId)
.filter(id => !!id);
if (versionIds.length === 0) {
this.installedWidgetVersions = [];
return of([]);
}
return this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true }).pipe(
switchMap(infos => {
if (infos.length === 0) {
return of([]);
}
const versionRequests = infos.map(info =>
this.iotHubApiService.getVersionInfo(info.publishedVersionId, { ignoreLoading: true })
);
return forkJoin(versionRequests);
}),
const versionRequests = versionIds.map(id =>
this.iotHubApiService.getVersionInfo(id, { ignoreLoading: true })
);
return forkJoin(versionRequests).pipe(
map(versions => {
this.installedWidgetVersions = versions.sort((a, b) => b.totalInstallCount - a.totalInstallCount);
return this.installedWidgetVersions;

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

@ -405,87 +405,92 @@
</button>
</div>
<!-- Loading -->
@if (isLoading) {
<div class="flex justify-center py-20">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
<div class="relative">
<!-- Error -->
@if (hasError && !isLoading) {
<div class="tb-iot-hub-empty-state flex flex-col items-center py-20 text-center">
<div class="tb-no-data-bg"></div>
<h3>{{ 'iot-hub.unable-to-load' | translate }}</h3>
<p>{{ 'iot-hub.unable-to-load-text' | translate }}</p>
<button mat-flat-button color="primary" (click)="loadItems()">
{{ 'iot-hub.retry' | translate }}
</button>
</div>
}
<!-- Empty state -->
@if (!isLoading && !hasError && items.length === 0) {
<div class="tb-iot-hub-empty-state flex flex-col items-center py-20 text-center">
<div class="tb-no-data-bg"></div>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.no-items-found-text' | translate }}</p>
@if (hasActiveFilters()) {
<button mat-flat-button color="primary" (click)="clearAllFilters()">
{{ 'iot-hub.clear-all-filters' | translate }}
</button>
<!-- Error -->
@if (hasError) {
<div class="tb-iot-hub-empty-state flex flex-col items-center py-20 text-center">
<div class="tb-no-service-bg"></div>
<h3>{{ 'iot-hub.network-server-unavailable' | translate }}</h3>
<p>{{ 'iot-hub.network-server-unavailable-text' | translate }}</p>
<button mat-flat-button color="primary" (click)="retryLoadItems()">
{{ 'iot-hub.try-again' | translate }}
</button>
</div>
}
</div>
}
<!-- Card grid -->
@if (!isLoading && !hasError && items.length > 0) {
<div class="tb-iot-hub-card-grid" [class.tb-iot-hub-card-grid-compact]="isCompactType">
@for (item of items; track item.id) {
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledItem(item)"
[installedItemsCount]="getInstalledItemsCount(item)"
[showTypeChip]="false"
[showSubtype]="!fixedSubType"
[showCreator]="!creatorId"
[mode]="mode"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)"
(addClick)="onItemAdd($event)"
(guideClick)="openInstallGuide($event)">
</tb-iot-hub-item-card>
<!-- Empty state -->
@if (!hasError && items.length === 0) {
<div class="tb-iot-hub-empty-state flex flex-col items-center py-20 text-center">
<div class="tb-no-data-bg"></div>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.no-items-found-text' | translate }}</p>
@if (hasActiveFilters()) {
<button mat-flat-button color="primary" (click)="clearAllFilters()">
{{ 'iot-hub.clear-all-filters' | translate }}
</button>
}
</div>
}
</div>
<!-- Pagination -->
<div class="tb-iot-hub-pagination flex shrink-0 items-center pt-6">
<div class="tb-iot-hub-pagination-pages flex items-center gap-px">
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of getPageNumbers(); track p) {
<button class="tb-iot-hub-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)">
{{ p + 1 }}
</button>
}
<button mat-icon-button [disabled]="pageIndex >= getTotalPages() - 1" (click)="goToPage(pageIndex + 1)">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<div class="tb-iot-hub-pagination-size">
<span>{{ 'iot-hub.items-per-page' | translate }}:</span>
<mat-select [value]="pageSize" (selectionChange)="onPageSizeChange($event.value)" class="tb-iot-hub-page-size-select">
@for (size of pageSizeOptions; track size) {
<mat-option [value]="size">{{ size }}</mat-option>
<!-- Card grid -->
@if (!hasError && items.length > 0) {
<div class="tb-iot-hub-card-grid" [class.tb-iot-hub-card-grid-compact]="isCompactType">
@for (item of items; track item.id) {
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledItem(item)"
[installedItemsCount]="getInstalledItemsCount(item)"
[showTypeChip]="false"
[showSubtype]="!fixedSubType"
[showCreator]="!creatorId"
[mode]="mode"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)"
(addClick)="onItemAdd($event)"
(guideClick)="openInstallGuide($event)">
</tb-iot-hub-item-card>
}
</mat-select>
</div>
</div>
<!-- Pagination -->
<div class="tb-iot-hub-pagination flex shrink-0 items-center pt-6">
<div class="tb-iot-hub-pagination-pages flex items-center gap-px">
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of getPageNumbers(); track p) {
<button class="tb-iot-hub-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)">
{{ p + 1 }}
</button>
}
<button mat-icon-button [disabled]="pageIndex >= getTotalPages() - 1" (click)="goToPage(pageIndex + 1)">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<div class="tb-iot-hub-pagination-size">
<span>{{ 'iot-hub.items-per-page' | translate }}:</span>
<mat-select [value]="pageSize" (selectionChange)="onPageSizeChange($event.value)" class="tb-iot-hub-page-size-select">
@for (size of pageSizeOptions; track size) {
<mat-option [value]="size">{{ size }}</mat-option>
}
</mat-select>
</div>
</div>
}
<!-- Loading -->
@if (isLoading) {
<div class="absolute inset-0 backdrop-grayscale" style="background: rgba(255, 255, 255, 0.72)">
<div class="flex justify-center py-20">
<mat-spinner diameter="64"></mat-spinner>
</div>
</div>
}
</div>
}
</div>
</mat-drawer-content>
</mat-drawer-container>

15
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss

@ -112,7 +112,8 @@
}
.tb-iot-hub-filters-wrapper {
position: absolute;
position: sticky;
max-height: calc(100vh - var(--mat-toolbar-standard-height, 64px));
inset: 0;
overflow-y: auto;
}
@ -146,10 +147,12 @@
width: 60%;
max-width: 550px;
min-width: 300px;
margin-bottom: -48px;
.embedded & {
margin-bottom: 0;
&:not(.embedded &) {
position: fixed;
top: var(--mat-toolbar-standard-height, 64px);
@media #{$mat-xs} {
top: var(--mat-toolbar-mobile-height, 56px);
}
}
}
@ -421,7 +424,7 @@
// Empty state text + no-data image
.tb-iot-hub-empty-state {
.tb-no-data-bg {
.tb-no-data-bg, .tb-no-service-bg {
flex: auto;
height: 100px;
}

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

@ -113,6 +113,7 @@ export class TbIotHubBrowseComponent implements OnInit, AfterViewInit, OnDestroy
isLoading = false;
hasError = false;
filterDrawerOpened = false;
private retryTimer: any = null;
textSearch = '';
_activeType: ItemType = ItemType.WIDGET;
@ -676,9 +677,23 @@ export class TbIotHubBrowseComponent implements OnInit, AfterViewInit, OnDestroy
});
}
retryLoadItems(): void {
if (this.retryTimer != null) {
clearTimeout(this.retryTimer);
}
this.isLoading = true;
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.loadItems();
}, 350);
}
loadItems(): void {
if (this.retryTimer != null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.isLoading = true;
this.hasError = false;
const sort = this.sortOptions[this.selectedSortIndex];
const sortOrder: SortOrder = { property: sort.value, direction: sort.direction };
const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder);
@ -702,6 +717,7 @@ export class TbIotHubBrowseComponent implements OnInit, AfterViewInit, OnDestroy
this.items = data.data;
this.totalElements = data.totalElements;
this.isLoading = false;
this.hasError = false;
},
error: () => {
this.isLoading = false;

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" [class.tb-iot-hub-card-preview-device]="item.type === ItemType.DEVICE">
<div class="tb-iot-hub-card-preview">
@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,11 +52,6 @@
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 {

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

@ -94,8 +94,7 @@
<!-- 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"
[class.dlg-preview-device]="item.type === ItemType.DEVICE">
[class.dlg-preview-carousel]="item.type === ItemType.SOLUTION_TEMPLATE && carouselImages.length > 1">
@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,11 +143,6 @@
&.dlg-preview-carousel {
aspect-ratio: 1.5;
}
&.dlg-preview-device {
background: white;
border: 1px solid rgba(0, 0, 0, 0.12);
}
}
.dlg-preview-img {

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

@ -15,13 +15,11 @@
limitations under the License.
-->
<div class="flex items-baseline gap-4 lt-md:-mb-6">
<div class="mb-10 flex items-baseline gap-4 lt-md:mb-4">
<ng-content></ng-content>
@if (!isLoading && totalElements > 0) {
<span class="tb-search-results-count tb-search-results-count-mobile">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
}
<span class="tb-search-results-count tb-search-results-count-mobile">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
</div>
<!-- Search + Sort row -->
<div class="flex items-center gap-4">
@ -56,92 +54,105 @@
</div>
<!-- Results count + Pagination (shown when results exist) -->
@if (!isLoading && totalElements > 0) {
<div class="tb-search-pagination-row">
<span class="tb-search-results-count">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
<div class="tb-search-pagination-row" [class.lt-md:!hidden]="totalElements === 0">
<span class="tb-search-results-count">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
@if (totalElements > 0) {
<div class="tb-search-pagination">
<div class="flex items-center gap-px">
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)">
<mat-icon>chevron_left</mat-icon>
<div class="flex items-center gap-px">
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of getPageNumbers(); track p) {
<button class="tb-search-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)">
{{ p + 1 }}
</button>
@for (p of getPageNumbers(); track p) {
<button class="tb-search-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)">
{{ p + 1 }}
</button>
}
<button mat-icon-button [disabled]="pageIndex >= totalPages - 1" (click)="goToPage(pageIndex + 1)">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<div class="tb-search-page-size">
<span>{{ 'iot-hub.items-per-page' | translate }}:</span>
<mat-select [value]="pageSize" (selectionChange)="onPageSizeChange($event.value)" class="tb-search-page-size-select">
@for (size of pageSizeOptions; track size) {
<mat-option [value]="size">{{ size }}</mat-option>
}
<button mat-icon-button [disabled]="pageIndex >= totalPages - 1" (click)="goToPage(pageIndex + 1)">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<div class="tb-search-page-size">
<span>{{ 'iot-hub.items-per-page' | translate }}:</span>
<mat-select [value]="pageSize" (selectionChange)="onPageSizeChange($event.value)" class="tb-search-page-size-select">
@for (size of pageSizeOptions; track size) {
<mat-option [value]="size">{{ size }}</mat-option>
}
</mat-select>
</div>
</mat-select>
</div>
</div>
}
}
</div>
<!-- No results count when 0 -->
@if (!isLoading && totalElements === 0 && searchText?.trim()) {
<div class="tb-search-pagination-row">
<span class="tb-search-results-count">
{{ 'iot-hub.results-count' | translate:{ count: 0 } }}
</span>
</div>
}
<div class="tb-search-divider"></div>
<!-- Loading -->
@if (isLoading) {
<div class="tb-search-loading flex justify-center py-12">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
<div class="relative min-h-0">
<!-- No results -->
@if (!isLoading && totalElements === 0 && searchText?.trim()) {
<div class="tb-search-empty flex flex-col items-center py-10">
<div class="tb-no-data-bg"></div>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.try-adjusting-search' | translate }}</p>
</div>
}
<!-- Results grouped by type -->
@if (!isLoading && resultGroups.length > 0) {
<div class="tb-search-divider"></div>
<div class="tb-search-categories">
@for (group of resultGroups; track group.type) {
<div class="tb-search-section">
<!-- Section header -->
<button mat-button class="tb-search-section-header" (click)="navigateToType(group.type)">
{{ getTypeLabel(group.type) }}
<mat-icon>chevron_right</mat-icon>
<div class="tb-results-container">
<!-- Error -->
@if (hasError) {
<div class="tb-search-empty flex flex-col items-center py-10">
<div class="tb-no-service-bg"></div>
<h3>{{ 'iot-hub.network-server-unavailable' | translate }}</h3>
<p>{{ 'iot-hub.network-server-unavailable-text' | translate }}</p>
<button mat-flat-button color="primary" (click)="retryLoadResults()">
{{ 'iot-hub.try-again' | translate }}
</button>
<!-- Card grid -->
<div class="tb-search-card-grid" [class.tb-search-card-grid-compact]="isCompactType(group.type)">
@for (item of group.items; track item.id) {
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledItem(item)"
[installedItemsCount]="getInstalledItemsCount(item)"
[showTypeChip]="false"
[showSubtype]="false"
[showCreator]="showCreator"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)">
</tb-iot-hub-item-card>
}
</div>
</div>
}
<!-- No results -->
@if (!hasError && totalElements === 0 && searchText?.trim()) {
<div class="tb-search-empty flex flex-col items-center py-10">
<div class="tb-no-data-bg"></div>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.try-adjusting-search' | translate }}</p>
</div>
}
<!-- Results grouped by type -->
@if (resultGroups.length > 0) {
<div class="tb-search-categories">
@for (group of resultGroups; track group.type) {
<div class="tb-search-section">
<!-- Section header -->
<button mat-button class="tb-search-section-header" (click)="navigateToType(group.type)">
{{ getTypeLabel(group.type) }}
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Card grid -->
<div class="tb-search-card-grid" [class.tb-search-card-grid-compact]="isCompactType(group.type)">
@for (item of group.items; track item.id) {
<tb-iot-hub-item-card
[item]="item"
[installedItem]="getInstalledItem(item)"
[installedItemsCount]="getInstalledItemsCount(item)"
[showTypeChip]="false"
[showSubtype]="false"
[showCreator]="showCreator"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)"
(updateClick)="updateItem($event)"
(deleteClick)="deleteInstalledItem($event)">
</tb-iot-hub-item-card>
}
</div>
</div>
}
</div>
}
</div>
}
<!-- Loading -->
@if (isLoading) {
<div class="absolute inset-0 z-10 backdrop-grayscale" style="background: rgba(255, 255, 255, 0.72)">
<div class="flex justify-center py-32">
<mat-spinner diameter="64"></mat-spinner>
</div>
</div>
}
</div>

42
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss

@ -20,7 +20,6 @@
:host {
display: flex;
flex-direction: column;
gap: 40px;
}
.tb-search-results-count-mobile {
@ -54,10 +53,11 @@
// Results count + Pagination row
.tb-search-pagination-row {
flex: 0 0 48px;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: -28px;
margin-top: 12px;
}
.tb-search-results-count {
@ -121,35 +121,24 @@
}
}
// Loading / Empty negative margin
.tb-search-loading,
.tb-search-empty {
margin-top: -20px;
}
// Empty state no-data-bg + text
.tb-search-empty {
.tb-no-data-bg {
.tb-no-data-bg, .tb-no-service-bg {
flex: auto;
height: 100px;
}
h3 {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.76);
margin: 0 0 8px;
font-size: 18px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
margin: 16px 0 8px;
}
p {
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
color: rgba(0, 0, 0, 0.54);
margin: 0;
margin: 0 0 24px;
}
}
@ -157,8 +146,15 @@
.tb-search-divider {
background: rgba(0, 0, 0, 0.12);
height: 1px;
margin-bottom: -20px;
margin-top: -20px;
min-height: 1px;
margin-top: 20px;
}
.tb-results-container {
position: relative;
height: 100%;
padding-top: 20px;
overflow: auto;
}
// Categories / sections
@ -166,9 +162,6 @@
display: flex;
flex-direction: column;
gap: 48px;
margin-top: -20px;
overflow: auto;
padding-top: 20px;
}
// Section header
@ -234,6 +227,7 @@
.tb-search-pagination-row {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 12px;
}

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

@ -18,7 +18,7 @@ import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angu
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { forkJoin, Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order';
import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
@ -65,6 +65,8 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
resultGroups: SearchResultGroup[] = [];
totalElements = 0;
isLoading = false;
hasError = false;
private retryTimer: any = null;
pageSize = 15;
pageIndex = 0;
@ -98,14 +100,10 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
this.loadInstalledItems();
this.searchSubscription = this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(text => {
this.isLoading = true;
this.pageIndex = 0;
return this.fetchResults(text);
})
).subscribe(result => {
this.applyResults(result.data, result.totalElements);
distinctUntilChanged()
).subscribe(() => {
this.pageIndex = 0;
this.loadResults();
});
this.loadResults();
}
@ -269,11 +267,37 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
void this.router.navigate(['/iot-hub/creator', creatorId]);
}
retryLoadResults(): void {
if (this.retryTimer != null) {
clearTimeout(this.retryTimer);
}
this.isLoading = true;
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.loadResults();
}, 350);
}
// Data loading
private loadResults(): void {
if (this.retryTimer != null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.isLoading = true;
this.fetchResults(this.searchText || '').subscribe(result => {
this.applyResults(result.data, result.totalElements);
// hasError stays as-is until the request actually succeeds
// (cleared in the `next` callback below).
this.fetchResults(this.searchText || '').subscribe({
next: result => {
this.applyResults(result.data, result.totalElements);
this.hasError = false;
},
error: () => {
this.isLoading = false;
this.hasError = true;
this.resultGroups = [];
this.totalElements = 0;
}
});
}
@ -282,7 +306,7 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
const sortOrder: SortOrder = { property: sort.value, direction: sort.direction };
const pageLink = new PageLink(this.pageSize, this.pageIndex, text.trim() || null, sortOrder);
const query = new MpItemVersionQuery(pageLink, { creatorId: this.creatorId || undefined });
return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true });
return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true, ignoreErrors: true });
}
private applyResults(data: MpItemVersionView[], totalElements: number): void {

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

@ -29,7 +29,7 @@
<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">
<div class="tb-iot-hub-widget-card-image">
@if (getItemImage(item); as imgUrl) {
<img [src]="imgUrl" alt="">
}

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

@ -131,7 +131,7 @@
padding: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 10px;
background: white;
background: radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px) 0 0 / 16px 16px rgba(0, 0, 0, 0.04);
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s;
@media #{$mat-md-lg} {
@ -144,13 +144,6 @@
}
}
// 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;

19
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html

@ -16,7 +16,24 @@
-->
<div class="tb-creator-page">
@if (creator) {
@if (hasError) {
<div class="tb-iot-hub-empty-state flex flex-1 flex-col items-center justify-center py-20 text-center">
<div class="tb-no-service-bg"></div>
<h3>{{ 'iot-hub.network-server-unavailable' | translate }}</h3>
<p>{{ 'iot-hub.network-server-unavailable-text' | translate }}</p>
<button mat-flat-button color="primary" (click)="retryLoadCreator()">
{{ 'iot-hub.try-again' | translate }}
</button>
@if (isLoading) {
<div class="absolute inset-0 flex items-center justify-center backdrop-grayscale" style="background: rgba(255, 255, 255, 0.72)">
<div class="flex justify-center py-20">
<mat-spinner diameter="64"></mat-spinner>
</div>
</div>
}
</div>
}
@if (creator && !hasError) {
<!-- Left: creator info -->
<div class="tb-creator-info">
<!-- Back link -->

31
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss

@ -31,6 +31,28 @@
height: 100%;
}
.tb-iot-hub-empty-state {
position: relative;
.tb-no-data-bg,
.tb-no-service-bg {
flex: auto;
height: 100px;
}
h3 {
font-size: 18px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
margin: 16px 0 8px;
}
p {
font-size: 14px;
color: rgba(0, 0, 0, 0.54);
margin: 0 0 24px;
}
}
// Left column: creator info
.tb-creator-info {
width: 360px;
@ -163,7 +185,7 @@ a.tb-creator-link:hover span {
margin-right: -123px;
}
.tb-search-categories {
.tb-results-container {
padding-right: 123px;
padding-bottom: 40px;
margin-right: -123px;
@ -203,7 +225,7 @@ a.tb-creator-link:hover span {
margin-right: -40px;
}
.tb-search-categories {
.tb-results-container {
padding-right: 40px;
margin-right: -40px;
}
@ -251,6 +273,7 @@ a.tb-creator-link:hover span {
@media #{$mat-lt-lg} {
:host {
height: auto;
flex: 1;
}
.tb-creator-page {
@ -273,7 +296,7 @@ a.tb-creator-link:hover span {
margin-right: -24px;
}
.tb-search-categories {
.tb-results-container {
padding-right: 24px;
margin-right: -24px;
}
@ -311,7 +334,7 @@ a.tb-creator-link:hover span {
margin-right: -16px;
}
.tb-search-categories {
.tb-results-container {
padding-right: 16px;
margin-right: -16px;
}

39
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts

@ -31,7 +31,10 @@ export class TbIotHubCreatorProfileComponent implements OnInit, OnDestroy {
creator: CreatorView;
creatorId: string;
isLoading = false;
hasError = false;
private retryTimer: any = null;
private destroy$ = new Subject<void>();
constructor(
@ -64,10 +67,40 @@ export class TbIotHubCreatorProfileComponent implements OnInit, OnDestroy {
void this.router.navigate(['/iot-hub']);
}
retryLoadCreator(): void {
// Debounce frequent retry clicks: show the loading spinner
// immediately and defer the actual loadCreator() call by 350ms
// so rapid-fire clicks coalesce into a single network round-trip.
// hasError is left as-is until the next request actually succeeds.
if (this.retryTimer != null) {
clearTimeout(this.retryTimer);
}
this.isLoading = true;
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.loadCreator();
}, 350);
}
private loadCreator(): void {
this.iotHubApiService.getCreatorProfile(this.creatorId, { ignoreLoading: true }).subscribe({
next: creator => this.creator = creator,
error: () => void this.router.navigate(['/iot-hub'])
if (this.retryTimer != null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.isLoading = true;
// hasError stays as-is until the request actually succeeds —
// cleared in the `next` callback below.
this.iotHubApiService.getCreatorProfile(this.creatorId, { ignoreLoading: true, ignoreErrors: true }).subscribe({
next: creator => {
this.creator = creator;
this.isLoading = false;
this.hasError = false;
},
error: () => {
this.creator = null;
this.isLoading = false;
this.hasError = true;
}
});
}

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

@ -153,7 +153,25 @@
}
</div>
@if (!isLoading) {
@if (hasError) {
<div class="tb-iot-hub-empty-state flex flex-col items-center py-20 text-center">
<div class="tb-no-service-bg"></div>
<h3>{{ 'iot-hub.network-server-unavailable' | translate }}</h3>
<p>{{ 'iot-hub.network-server-unavailable-text' | translate }}</p>
<button mat-flat-button color="primary" (click)="retryLoadPopularItems()">
{{ 'iot-hub.try-again' | translate }}
</button>
@if (isLoading) {
<div class="absolute inset-0 backdrop-grayscale" style="background: rgba(255, 255, 255, 0.72)">
<div class="flex justify-center py-20">
<mat-spinner diameter="64"></mat-spinner>
</div>
</div>
}
</div>
}
@if (!isLoading && !hasError) {
@if (hasAnyPopularItems()) {
<div class="tb-iot-hub-divider tb-iot-hub-divider-bleed"></div>
<h2 class="tb-iot-hub-most-popular-title">{{ 'iot-hub.most-popular-in-iot-hub' | translate }}</h2>
@ -299,7 +317,7 @@
}
}
@if (isLoading) {
@if (isLoading && !hasError) {
<div class="flex justify-center py-20">
<mat-spinner diameter="40"></mat-spinner>
</div>
@ -307,7 +325,7 @@
</div>
<!-- Become a Creator -->
@if (!isLoading) {
@if (!isLoading && !hasError) {
<div class="tb-iot-hub-divider"></div>
<div class="tb-iot-hub-become-creator">
<h2>{{ 'iot-hub.become-a-creator' | translate }}</h2>

22
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss

@ -542,6 +542,28 @@
margin: 0;
}
.tb-iot-hub-empty-state {
position: relative;
.tb-no-data-bg,
.tb-no-service-bg {
flex: auto;
height: 100px;
}
h3 {
font-size: 18px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
margin: 16px 0 8px;
}
p {
font-size: 14px;
color: rgba(0, 0, 0, 0.54);
margin: 0 0 24px;
}
}
// Sections — inside main-content, no individual padding needed
.tb-iot-hub-section {
}

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

@ -134,6 +134,8 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
installedItemsCount = 0;
isLoading = true;
hasError = false;
private retryTimer: any = null;
bigCardCount = 5;
compactCardCount = 6;
@ -444,7 +446,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}
openSignup(): void {
window.open('https://iothub.thingsboard.io/signup', '_blank');
window.open(this.iotHubApiService.baseUrl + '/signup', '_blank');
}
private updateCardCounts(): void {
@ -479,9 +481,32 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}
}
retryLoadPopularItems(): void {
// Debounce frequent retry clicks: show the loading spinner
// immediately and defer the actual loadPopularItems() call by
// 350ms so rapid-fire clicks coalesce into a single request set.
// hasError stays as-is until the next request actually succeeds.
if (this.retryTimer != null) {
clearTimeout(this.retryTimer);
}
this.isLoading = true;
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.loadPopularItems();
}, 350);
}
private loadPopularItems(): void {
if (this.retryTimer != null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.isLoading = true;
// hasError is intentionally NOT reset here — the error UI stays
// visible until the next forkJoin actually succeeds (cleared in
// the `next` callback below).
const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC };
const config = { ignoreLoading: true };
const config = { ignoreLoading: true, ignoreErrors: true };
const installedPageLink = new PageLink(10000, 0);
const buildQuery = (type: ItemType, size: number): MpItemVersionQuery => {
@ -519,9 +544,17 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
this.installedRuleChainCounts = results.installedRuleChainCounts;
this.installedItemsCount = results.installedCount;
this.isLoading = false;
this.hasError = false;
},
error: () => {
this.isLoading = false;
this.hasError = true;
this.popularWidgets = [];
this.popularSolutionTemplates = [];
this.popularCalcFields = [];
this.popularAlarmRules = [];
this.popularRuleChains = [];
this.popularDevices = [];
}
});
}

1
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.scss

@ -25,7 +25,6 @@
background: white;
border-radius: 8px;
padding: 40px 40px 48px;
overflow: hidden;
}
.tb-iot-hub-back-btn {

2
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts

@ -120,7 +120,7 @@ export class TbIotHubItemsPageComponent implements OnInit {
}
openSignup(): void {
window.open('https://iothub.thingsboard.io/signup', '_blank');
window.open(this.iotHubApiService.baseUrl + '/signup', '_blank');
}
private loadInstalledCount(): void {

8
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.scss

@ -56,7 +56,7 @@ tb-iot-hub-search {
margin-right: -123px;
}
.tb-search-categories {
.tb-results-container {
padding-right: 123px;
padding-bottom: 40px;
margin-right: -123px;
@ -80,7 +80,7 @@ tb-iot-hub-search {
margin-right: -40px;
}
.tb-search-categories {
.tb-results-container {
padding-right: 40px;
margin-right: -40px;
}
@ -104,7 +104,7 @@ tb-iot-hub-search {
margin-right: -24px;
}
.tb-search-categories {
.tb-results-container {
padding-right: 24px;
padding-bottom: 24px;
margin-right: -24px;
@ -133,7 +133,7 @@ tb-iot-hub-search {
margin-right: -16px;
}
.tb-search-categories {
.tb-results-container {
padding-right: 16px;
padding-bottom: 16px;
margin-right: -16px;

23
ui-ngx/src/assets/home/no-service.svg

@ -0,0 +1,23 @@
<svg width="140" height="140" viewBox="0 0 140 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_fetch_error)">
<path opacity="0.06" fill-rule="evenodd" clip-rule="evenodd" d="M103.968 6.58246C87.6317 -0.131145 69.5645 6.70984 52.3206 12.8397C34.9036 19.031 15.4676 24.5221 6.28549 41.3064C-3.06157 58.3922 -2.96976 80.7328 6.47606 96.6404C14.774 110.615 35.0941 106.337 49.2539 113.669C63.5704 121.082 72.1004 142.391 88.3078 139.014C98.9862 136.79 105.249 127.095 110.677 116.374C112.406 112.96 114.051 109.441 115.726 106.027C116.901 103.632 118.091 101.288 119.337 99.0679C128.547 82.6501 142.092 66.5913 139.085 48.4973C135.871 29.1536 121.325 13.7158 103.968 6.58246Z" fill="#3D50F5"/>
<circle opacity="0.16" cx="94.1" cy="18.1" r="2.1" fill="#3D50F5"/>
<circle opacity="0.16" cx="110.1" cy="37.1" r="2.1" fill="#3D50F5"/>
<circle opacity="0.16" cx="27.65" cy="59.85" r="1.05" fill="#3D50F5"/>
<circle opacity="0.16" cx="82.05" cy="116.05" r="1.05" fill="#3D50F5"/>
<circle opacity="0.16" cx="123.4" cy="52.4" r="1.4" fill="#3D50F5"/>
<circle opacity="0.16" cx="18.4" cy="48.4" r="1.4" fill="#3D50F5"/>
<circle opacity="0.16" cx="35.7" cy="100.7" r="0.7" fill="#3D50F5"/>
<circle opacity="0.16" cx="119.7" cy="78.7" r="0.7" fill="#3D50F5"/>
<path opacity="0.3" d="M38 70 A 32 32 0 0 1 102 70" stroke="#3D50F5" stroke-width="6" stroke-linecap="round" fill="none"/>
<path opacity="0.55" d="M48 74 A 22 22 0 0 1 92 74" stroke="#3D50F5" stroke-width="6" stroke-linecap="round" fill="none"/>
<path opacity="0.85" d="M58 78 A 12 12 0 0 1 82 78" stroke="#3D50F5" stroke-width="6" stroke-linecap="round" fill="none"/>
<circle cx="70" cy="94" r="4.5" fill="#3D50F5"/>
<line x1="30" y1="36" x2="110" y2="104" stroke="#3D50F5" stroke-width="7" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_fetch_error">
<rect width="140" height="140" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

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

@ -4275,6 +4275,9 @@
"results-count": "{{count}} results",
"unable-to-load": "Unable to load",
"unable-to-load-text": "There was a problem loading items. Please try again.",
"network-server-unavailable": "Network or server unavailable",
"network-server-unavailable-text": "We couldn’t reach the catalog. Please try again in a moment.",
"try-again": "Try again",
"retry": "Retry",
"no-items-found": "No items found",
"no-items-found-text": "Try adjusting your search or filters.",

25
ui-ngx/src/form.scss

@ -741,6 +741,31 @@
}
}
.tb-no-service-bg {
margin: 10px;
position: relative;
flex: 1;
width: 100%;
max-height: 100px;
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $tb-primary-color;
mask-image: url(/assets/home/no-service.svg);
-webkit-mask-image: url(/assets/home/no-service.svg);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-size: contain;
mask-position: center;
-webkit-mask-position: center;
}
}
.tb-no-data-text {
font-weight: 500;
font-size: 14px;

Loading…
Cancel
Save