Browse Source

feat(iot-hub): unified network-unavailable error state with debounced retry

Bring the same fetch-failure UI to every IoT Hub surface (browse,
search, home, creator profile) so all of them recover gracefully
from network / server outages instead of dead-ending on a global
error toast or a hard redirect.

- Add a tb-no-service-bg global utility in form.scss that mirrors
  tb-no-data-bg but renders /assets/home/no-service.svg (copied
  from thingsboard.io). Same primary-color mask treatment, so PE
  builds retint without re-exporting the asset.
- Add hasError / retryTimer state to the four affected components
  and switch their data calls to ignoreErrors: true so the global
  toast no longer fires. hasError flips to true on error, stays
  true while retries are pending, and is only cleared in the
  `next` callback of the next request that actually succeeds.
- Add retryLoad* helpers (retryLoadItems / retryLoadResults /
  retryLoadPopularItems / retryLoadCreator) that set isLoading=true
  immediately and schedule the real load via a 350ms setTimeout so
  rapid-fire retry clicks coalesce into one network round-trip.
- Search component drops the inline switchMap-on-searchSubject path
  and pipes the debounced subject straight into loadResults() so
  search-triggered loads go through the same error / spinner /
  retry plumbing.
- Render the error block consistently across surfaces: in browse
  and search inside their respective results container, on the
  home page right after the category cards, on the creator profile
  as the whole page body (replacing the old router.navigate to
  /iot-hub). Each block uses .tb-no-service-bg + the new
  iot-hub.network-server-unavailable / -text / try-again locale
  keys and binds the primary button to the matching retryLoad*
  method.
- Add the matching .tb-iot-hub-empty-state typography (18/500
  title, 14/0.54 body, 24px button gap) to the home and creator
  profile SCSS so the wrapper matches the layout already used in
  browse / search.

Also pick up an unrelated dashboard-widget-select adjustment touched
in the same workspace.
pull/15772/head
Igor Kulikov 4 weeks ago
parent
commit
bb47013d1b
  1. 22
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  2. 10
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html
  3. 2
      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. 14
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html
  6. 17
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss
  7. 48
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts
  8. 19
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html
  9. 22
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss
  10. 39
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts
  11. 24
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html
  12. 22
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss
  13. 37
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  14. 2
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts
  15. 23
      ui-ngx/src/assets/home/no-service.svg
  16. 3
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  17. 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;

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

@ -410,11 +410,11 @@
<!-- Error -->
@if (hasError) {
<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 }}
<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>
}

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

@ -424,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;

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

@ -90,8 +90,20 @@
<div class="relative min-h-0">
<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>
</div>
}
<!-- No results -->
@if (totalElements === 0 && searchText?.trim()) {
@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>

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

@ -123,27 +123,22 @@
// 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;
}
}

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 {

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 -->

22
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;

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 = [];
}
});
}

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 {

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

@ -3789,6 +3789,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

@ -720,6 +720,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