Browse Source

UI: extract BreadcrumbService, split notification-bell/github-badge into their own modules, redesign dashboard/breadcrumb toolbars

Move breadcrumb resolution out of BreadcrumbComponent into a dedicated BreadcrumbService so the breadcrumb stream can be consumed across the new top toolbar without duplicating ActivatedRoute walking. Introduce a main-toolbar models file shared by HomeComponent for the redesigned header. Promote NotificationBell and GithubBadge to standalone NgModules so they can be re-used outside HomeModule. Refresh dashboard-page / dashboard-toolbar / default- and entity-state controllers and routing data to match the new toolbar shape (drop legacy icon fields, tighten padding, switch flex axes). User-menu spacing follow-up.
pull/15888/head
Igor Kulikov 3 months ago
parent
commit
2635757eb5
  1. 140
      ui-ngx/src/app/core/services/breadcrumb.service.ts
  2. 81
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html
  3. 9
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss
  4. 16
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  5. 9
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html
  6. 25
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss
  7. 19
      ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.html
  8. 30
      ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.scss
  9. 41
      ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.html
  10. 43
      ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.scss
  11. 6
      ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts
  12. 1
      ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.scss
  13. 31
      ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.ts
  14. 35
      ui-ngx/src/app/modules/home/components/github-badge/github-badge.module.ts
  15. 6
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  16. 38
      ui-ngx/src/app/modules/home/components/notification/notification-bell.module.ts
  17. 93
      ui-ngx/src/app/modules/home/home.component.html
  18. 2
      ui-ngx/src/app/modules/home/home.component.scss
  19. 28
      ui-ngx/src/app/modules/home/home.component.ts
  20. 12
      ui-ngx/src/app/modules/home/home.module.ts
  21. 28
      ui-ngx/src/app/modules/home/models/main-toolbar.models.ts
  22. 3
      ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts
  23. 6
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts
  24. 3
      ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts
  25. 8
      ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts
  26. 77
      ui-ngx/src/app/shared/components/breadcrumb.component.html
  27. 1
      ui-ngx/src/app/shared/components/breadcrumb.component.scss
  28. 128
      ui-ngx/src/app/shared/components/breadcrumb.component.ts
  29. 9
      ui-ngx/src/app/shared/components/user-menu.component.scss
  30. 4
      ui-ngx/src/theme.scss

140
ui-ngx/src/app/core/services/breadcrumb.service.ts

@ -0,0 +1,140 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Injectable } from '@angular/core';
import { BehaviorSubject, merge, shareReplay, Subject, Subscription } from 'rxjs';
import { BreadCrumb, BreadCrumbConfig } from '@shared/components/breadcrumb';
import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { MenuService } from '@core/services/menu.service';
import { distinctUntilChanged, filter, first, map, switchMap } from 'rxjs/operators';
import { MenuSection, menuSectionMap } from '@core/services/menu.models';
import { guid } from '@core/utils';
import { ActiveComponentService } from '@core/services/active-component.service';
@Injectable({
providedIn: 'root'
})
export class BreadcrumbService {
private updateBreadcrumbsSubscription: Subscription = null;
private breadcrumbsSubject: Subject<Array<BreadCrumb>> = new BehaviorSubject<Array<BreadCrumb>>([]);
private activeComponent: any;
get breadcrumbs$() {
return this.breadcrumbsSubject.asObservable().pipe(shareReplay(1));
}
get lastBreadcrumb$() {
return this.breadcrumbs$.pipe<BreadCrumb>(
map( (breadcrumbs: BreadCrumb[]) => breadcrumbs.length ? breadcrumbs[breadcrumbs.length - 1] : null)
);
}
constructor(private router: Router,
private activatedRoute: ActivatedRoute,
private translate: TranslateService,
private menuService: MenuService,
private activeComponentService: ActiveComponentService) {
merge(this.router.events.pipe(
filter((event) => event instanceof NavigationEnd ),
distinctUntilChanged()), this.menuService.availableMenuSections()).pipe(
switchMap(() => this.menuService.availableMenuSections().pipe(first())),
map( (sections: MenuSection[]) => this.buildBreadCrumbs(this.activatedRoute.snapshot, sections) )
).subscribe((breadcrumbs: BreadCrumb[]) => this.breadcrumbsSubject.next(breadcrumbs) );
this.activeComponentService.onActiveComponentChanged().subscribe(comp => this.setActiveComponent(comp));
}
private setActiveComponent(activeComponent: any) {
if (this.updateBreadcrumbsSubscription) {
this.updateBreadcrumbsSubscription.unsubscribe();
this.updateBreadcrumbsSubscription = null;
}
this.activeComponent = activeComponent;
if (this.activeComponent) {
if (this.activeComponent.updateBreadcrumbs) {
this.updateBreadcrumbsSubscription = this.activeComponent.updateBreadcrumbs.subscribe(() => {
this.menuService.availableMenuSections().pipe(first()).subscribe((sections: MenuSection[]) => {
const breadcrumbs = this.buildBreadCrumbs(this.activatedRoute.snapshot, sections);
this.breadcrumbsSubject.next(breadcrumbs);
});
});
}
}
}
private buildBreadCrumbs(route: ActivatedRouteSnapshot, availableMenuSections: MenuSection[],
breadcrumbs: Array<BreadCrumb> = [],
lastChild?: ActivatedRouteSnapshot): Array<BreadCrumb> {
if (!lastChild) {
lastChild = this.lastChild(route);
}
let newBreadcrumbs = breadcrumbs;
if (route.routeConfig && route.routeConfig.data) {
const breadcrumbConfig = route.routeConfig.data.breadcrumb as BreadCrumbConfig<any>;
if (breadcrumbConfig && !breadcrumbConfig.skip) {
let labelFunction: () => string;
let section: MenuSection = null;
if (breadcrumbConfig.menuId) {
section = availableMenuSections.find(menu => menu.id === breadcrumbConfig.menuId);
if (!section) {
section = menuSectionMap.get(breadcrumbConfig.menuId);
}
}
const label = section?.name || breadcrumbConfig.label || 'home.home';
const customTranslate = section?.customTranslate || false;
if (breadcrumbConfig.labelFunction) {
labelFunction = () => {
if (this.activeComponent) {
try {
return breadcrumbConfig.labelFunction(route, this.translate, this.activeComponent, lastChild.data);
} catch {
return label;
}
} else {
return label;
}
}
}
const link = [ route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/') ];
const breadcrumb = {
id: guid(),
label,
customTranslate,
labelFunction,
link,
queryParams: null
};
newBreadcrumbs = [...breadcrumbs, breadcrumb];
}
}
if (route.firstChild) {
return this.buildBreadCrumbs(route.firstChild, availableMenuSections, newBreadcrumbs, lastChild);
}
return newBreadcrumbs;
}
private lastChild(route: ActivatedRouteSnapshot) {
let child = route;
while (child.firstChild !== null) {
child = child.firstChild;
}
return child;
}
}

81
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html

@ -15,6 +15,20 @@
limitations under the License. limitations under the License.
--> -->
<ng-template #dashboardBreadcrumbsTpl>
<tb-states-component class="lt-md:flex-1"
[statesControllerId]="dashboardConfiguration.settings.stateControllerId"
[dashboardCtrl]="this"
[dashboardId]="setStateDashboardId ? dashboard.id.id : ''"
[isMobile]="isMobile"
[state]="dashboardCtx.state"
[currentState]="currentState"
[syncStateWithQueryParam]="syncStateWithQueryParam"
[states]="dashboardConfiguration.states">
</tb-states-component>
</ng-template>
<div class="tb-dashboard-page mat-content flex-1" [class.mobile-app]="isMobileApp && !isEdit" tb-toast <div class="tb-dashboard-page mat-content flex-1" [class.mobile-app]="isMobileApp && !isEdit" tb-toast
tb-fullscreen [fullscreenElement]="elRef.nativeElement" [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen"> tb-fullscreen [fullscreenElement]="elRef.nativeElement" [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet> <tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
@ -50,17 +64,22 @@
[dashboardCtrl]="this"> [dashboardCtrl]="this">
</tb-select-dashboard-breakpoint> </tb-select-dashboard-breakpoint>
</div> </div>
<tb-states-component class="lt-md:flex-1"
[statesControllerId]="'default'"
[dashboardCtrl]="this"
[dashboardId]="setStateDashboardId ? dashboard.id.id : ''"
[isMobile]="isMobile"
[state]="dashboardCtx.state"
[currentState]="currentState"
[syncStateWithQueryParam]="syncStateWithQueryParam"
[states]="dashboardConfiguration.states">
</tb-states-component>
} @else {
<tb-notification-bell class="gt-sm:!hidden"></tb-notification-bell>
<tb-breadcrumb class="lt-md:flex-1"
[componentBreadcrumbsTpl]="dashboardBreadcrumbsTpl">
</tb-breadcrumb>
} }
<tb-states-component class="lt-md:flex-1"
[statesControllerId]="isEdit ? 'default' : dashboardConfiguration.settings.stateControllerId"
[dashboardCtrl]="this"
[dashboardId]="setStateDashboardId ? dashboard.id.id : ''"
[isMobile]="isMobile"
[state]="dashboardCtx.state"
[currentState]="currentState"
[syncStateWithQueryParam]="syncStateWithQueryParam"
[states]="dashboardConfiguration.states">
</tb-states-component>
@if (isEdit) { @if (isEdit) {
<button class="gt-md:!hidden" mat-icon-button <button class="gt-md:!hidden" mat-icon-button
matTooltip="{{'dashboard.manage-states' | translate}}" matTooltip="{{'dashboard.manage-states' | translate}}"
@ -79,6 +98,13 @@
@if (showDashboardLogo()) { @if (showDashboardLogo()) {
<tb-logo [link]="dashboardLogoLink" [src]="dashboardLogo | image | async" class="dashboard_logo"></tb-logo> <tb-logo [link]="dashboardLogoLink" [src]="dashboardLogo | image | async" class="dashboard_logo"></tb-logo>
} }
@if (!isEdit) {
<button mat-icon-button
[class.!hidden]="forceFullscreen || isFullscreen"
class="-ml-2 gt-sm:!hidden" (click)="toggleSidenav()">
<mat-icon class="material-icons">menu</mat-icon>
</button>
}
</div> </div>
<div class="tb-dashboard-action-panel"> <div class="tb-dashboard-action-panel">
<button [class.!hidden]="!showCloseToolbar()" mat-icon-button class="close-action" <button [class.!hidden]="!showCloseToolbar()" mat-icon-button class="close-action"
@ -97,12 +123,20 @@
(click)="toggleLayouts()"> (click)="toggleLayouts()">
<mat-icon>{{isRightLayoutOpened ? 'arrow_back' : 'menu'}}</mat-icon> <mat-icon>{{isRightLayoutOpened ? 'arrow_back' : 'menu'}}</mat-icon>
</button> </button>
<button [class.!hidden]="hideFullscreenButton()" class="xs:!hidden" mat-icon-button <div class="flex flex-row-reverse items-center lt-md:!hidden">
matTooltip="{{(isFullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}" @if (!isEdit) {
matTooltipPosition="below" <tb-notification-bell></tb-notification-bell>
(click)="isFullscreen = !isFullscreen"> }
<mat-icon>{{ isFullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon> <button [class.!hidden]="hideFullscreenButton()" mat-icon-button
</button> matTooltip="{{(isFullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="below"
(click)="isFullscreen = !isFullscreen">
<mat-icon>{{ isFullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
</div>
<mat-divider
[class.!hidden]="readonly || isEdit"
class="tb-toolbar-divider -mr-1.5 ml-2 lt-md:!hidden lt-lg:mr-[-3px]" vertical></mat-divider>
<button [class.!hidden]="!currentDashboardId || isMobileApp || !displayExport() || isEdit" mat-icon-button <button [class.!hidden]="!currentDashboardId || isMobileApp || !displayExport() || isEdit" mat-icon-button
matTooltip="{{'dashboard.export' | translate}}" matTooltip="{{'dashboard.export' | translate}}"
matTooltipPosition="below" matTooltipPosition="below"
@ -191,7 +225,7 @@
{{ 'dashboard.settings' | translate }} {{ 'dashboard.settings' | translate }}
</button> </button>
} }
<button [class.!hidden]="!currentDashboardId || isEdit || isMobileApp || !isTenantAdmin() || !displayUpdateDashboardImage()" mat-icon-button <button [class.!hidden]="!currentDashboardId || isEdit || isMobileApp || !canEdit() || readonly || !displayUpdateDashboardImage()" mat-icon-button
matTooltip="{{'dashboard.update-image' | translate}}" matTooltip="{{'dashboard.update-image' | translate}}"
matTooltipPosition="below" matTooltipPosition="below"
(click)="updateDashboardImage($event)"> (click)="updateDashboardImage($event)">
@ -264,12 +298,17 @@
[aliasController]="dashboardCtx.aliasController"> [aliasController]="dashboardCtx.aliasController">
</tb-aliases-entity-select> </tb-aliases-entity-select>
@if (!isEdit && !widgetEditMode && !embedded && displayDashboardsSelect()) { @if (!isEdit && !widgetEditMode && !embedded && displayDashboardsSelect()) {
<tb-dashboard-select [(ngModel)]="currentDashboardId" <tb-dashboard-select class="ml-2" [(ngModel)]="currentDashboardId"
(ngModelChange)="currentDashboardIdChanged(currentDashboardId)" (ngModelChange)="currentDashboardIdChanged(currentDashboardId)"
[customerId]="currentCustomerId" [customerId]="currentCustomerId"
[dashboardsScope]="currentDashboardScope"> [dashboardsScope]="currentDashboardScope">
</tb-dashboard-select> </tb-dashboard-select>
} }
@if (!isEdit && !widgetEditMode) {
<div class="-mr-1.5 md-lg:!hidden lt-md:!hidden" [class.!hidden]="githubBadge.hideGithubBadge">
<tb-github-badge #githubBadge ></tb-github-badge>
</div>
}
</div> </div>
</div> </div>
</tb-dashboard-toolbar> </tb-dashboard-toolbar>
@ -350,7 +389,7 @@
@if (!readonly && (hideToolbar || widgetEditMode)) { @if (!readonly && (hideToolbar || widgetEditMode)) {
<section data-html2canvas-ignore class="tb-header-buttons tb-enter-edit-mode flex flex-row flex-wrap items-end justify-start"> <section data-html2canvas-ignore class="tb-header-buttons tb-enter-edit-mode flex flex-row flex-wrap items-end justify-start">
<button [class.!hidden]="isEdit" <button [class.!hidden]="isEdit"
color="primary" color="#fff"
mat-mini-fab mat-mini-fab
class="tb-btn-header tb-btn-edit" class="tb-btn-header tb-btn-edit"
(click)="toggleDashboardEditMode()" (click)="toggleDashboardEditMode()"
@ -359,7 +398,7 @@
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
</button> </button>
<button [class.!hidden]="!isEdit" <button [class.!hidden]="!isEdit"
color="primary" color="#fff"
mat-mini-fab mat-mini-fab
class="tb-btn-header tb-btn-edit" class="tb-btn-header tb-btn-edit"
(click)="saveDashboard()" (click)="saveDashboard()"
@ -369,7 +408,7 @@
<mat-icon>done</mat-icon> <mat-icon>done</mat-icon>
</button> </button>
<button [class.!hidden]="!isEdit" <button [class.!hidden]="!isEdit"
color="primary" color="#fff"
mat-mini-fab mat-mini-fab
class="tb-btn-header tb-btn-edit" class="tb-btn-header tb-btn-edit"
(click)="toggleDashboardEditMode()" (click)="toggleDashboardEditMode()"

9
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss

@ -15,9 +15,9 @@
*/ */
@import "../../../../../scss/constants"; @import "../../../../../scss/constants";
$toolbar-height: 50px !default; $toolbar-height: 56px !default;
$fullscreen-toolbar-height: 64px !default; $fullscreen-toolbar-height: 56px !default;
$mobile-toolbar-height: 84px !default; $mobile-toolbar-height: 96px !default;
$mobile-app-toolbar-height: 40px !default; $mobile-app-toolbar-height: 40px !default;
tb-dashboard-page { tb-dashboard-page {
@ -79,8 +79,7 @@ div.tb-dashboard-page {
} }
.mat-divider-vertical.tb-toolbar-divider { .mat-divider-vertical.tb-toolbar-divider {
border-right-color: rgba(255,255,255,0.75); height: 100%;
height: 30px;
} }
.tb-dashboard-container { .tb-dashboard-container {

16
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts

@ -149,6 +149,7 @@ import {
MoveWidgetsDialogResult MoveWidgetsDialogResult
} from '@home/components/dashboard-page/layout/move-widgets-dialog.component'; } from '@home/components/dashboard-page/layout/move-widgets-dialog.component';
import { HttpStatusCode } from '@angular/common/http'; import { HttpStatusCode } from '@angular/common/http';
import { MainToolbarComponent } from '@home/models/main-toolbar.models';
// @dynamic // @dynamic
@Component({ @Component({
@ -159,7 +160,7 @@ import { HttpStatusCode } from '@angular/common/http';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false standalone: false
}) })
export class DashboardPageComponent extends PageComponent implements IDashboardController, HasDirtyFlag, OnInit, AfterViewInit, OnDestroy { export class DashboardPageComponent extends PageComponent implements IDashboardController, HasDirtyFlag, MainToolbarComponent, OnInit, AfterViewInit, OnDestroy {
LayoutType = LayoutType; LayoutType = LayoutType;
@ -267,7 +268,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
dashboardLogoLink = this.getDashboardLogoLink(); dashboardLogoLink = this.getDashboardLogoLink();
private dashboardLogoCache: SafeUrl; private dashboardLogoCache: SafeUrl;
private defaultDashboardLogo = 'assets/logo_title_white.svg'; private defaultDashboardLogo = 'assets/logo_title_black.svg';
private dashboardResize$: ResizeObserver; private dashboardResize$: ResizeObserver;
@ -321,6 +322,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
updateBreadcrumbs = new EventEmitter(); updateBreadcrumbs = new EventEmitter();
hideMainToolbar = true;
toggleSideBar = new EventEmitter();
private rxSubscriptions = new Array<Subscription>(); private rxSubscriptions = new Array<Subscription>();
get toolbarOpened(): boolean { get toolbarOpened(): boolean {
@ -867,6 +871,10 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
return this.authUser.authority === Authority.SYS_ADMIN; return this.authUser.authority === Authority.SYS_ADMIN;
} }
public canEdit(): boolean {
return this.isTenantAdmin() || (this.isSystemAdmin() && this.widgetEditMode);
}
public exportDashboard($event: Event) { public exportDashboard($event: Event) {
if ($event) { if ($event) {
$event.preventDefault(); $event.preventDefault();
@ -1745,6 +1753,10 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
} }
} }
toggleSidenav() {
this.toggleSideBar.emit();
}
get showMainLayoutFiller(): boolean { get showMainLayoutFiller(): boolean {
const layoutMaxWidth = this.dashboardUtils.getBreakpointInfoById(this.layouts.main.layoutCtx.breakpoint)?.maxWidth || Infinity; const layoutMaxWidth = this.dashboardUtils.getBreakpointInfoById(this.layouts.main.layoutCtx.breakpoint)?.maxWidth || Infinity;
const dashboardMaxWidth = this.dashboardUtils.getBreakpointInfoById(this.dashboardCtx.breakpoint)?.maxWidth || Infinity; const dashboardMaxWidth = this.dashboardUtils.getBreakpointInfoById(this.dashboardCtx.breakpoint)?.maxWidth || Infinity;

9
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html

@ -15,18 +15,17 @@
limitations under the License. limitations under the License.
--> -->
<mat-fab-toolbar [isOpen]="toolbarOpened" color="primary" direction="left" <mat-fab-toolbar [isOpen]="toolbarOpened" direction="left"
[class.is-fullscreen]="forceFullscreen" [class.is-fullscreen]="forceFullscreen">
[class.mat-elevation-z1]="forceFullscreen && toolbarOpened">
<mat-fab-trigger> <mat-fab-trigger>
<button mat-fab color="primary" <button mat-fab color="#fff"
[matTooltip]="!toolbarOpened ? ('dashboard.open-toolbar' | translate) : ''" [matTooltip]="!toolbarOpened ? ('dashboard.open-toolbar' | translate) : ''"
matTooltipPosition="below" matTooltipPosition="below"
(click)="onTriggerClick()"> (click)="onTriggerClick()">
<mat-icon>more_horiz</mat-icon> <mat-icon>more_horiz</mat-icon>
</button> </button>
</mat-fab-trigger> </mat-fab-trigger>
<mat-toolbar color="primary"> <mat-toolbar>
<mat-fab-actions class="mat-toolbar-tools"> <mat-fab-actions class="mat-toolbar-tools">
<ng-content></ng-content> <ng-content></ng-content>
</mat-fab-actions> </mat-fab-actions>

25
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss

@ -15,11 +15,11 @@
*/ */
@import "../../../../../scss/constants"; @import "../../../../../scss/constants";
$toolbar-height: 50px !default; $toolbar-height: 56px !default;
$fullscreen-toolbar-height: 64px !default; $fullscreen-toolbar-height: 56px !default;
$mobile-toolbar-height: 80px !default; $mobile-toolbar-height: 96px !default;
$half-mobile-toolbar-height: 40px !default; $half-mobile-toolbar-height: 48px !default;
$mobile-toolbar-height-total: 84px !default; $mobile-toolbar-height-total: 96px !default;
$mobile-app-toolbar-height: 40px !default; $mobile-app-toolbar-height: 40px !default;
@ -97,6 +97,7 @@ tb-dashboard-toolbar {
.mat-fab-toolbar-wrapper { .mat-fab-toolbar-wrapper {
height: $mobile-toolbar-height-total; height: $mobile-toolbar-height-total;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
@media #{$mat-gt-sm} { @media #{$mat-gt-sm} {
height: $toolbar-height; height: $toolbar-height;
@ -121,12 +122,12 @@ tb-dashboard-toolbar {
mat-fab-actions { mat-fab-actions {
margin-top: 0; margin-top: 0;
padding: 0 16px !important;
font-size: 16px; font-size: 16px;
@media #{$mat-lt-md} { @media #{$mat-lt-md} {
height: $mobile-toolbar-height; height: $mobile-toolbar-height;
max-height: $mobile-toolbar-height; max-height: $mobile-toolbar-height;
padding: 0 8px !important;
} }
.close-action { .close-action {
@ -138,14 +139,14 @@ tb-dashboard-toolbar {
height: $mobile-toolbar-height; height: $mobile-toolbar-height;
@media #{$mat-gt-sm} { @media #{$mat-gt-sm} {
height: 46px; height: $toolbar-height;
} }
.tb-dashboard-action-panels { .tb-dashboard-action-panels {
height: $mobile-toolbar-height; height: $mobile-toolbar-height;
@media #{$mat-gt-sm} { @media #{$mat-gt-sm} {
height: 46px; height: $toolbar-height;
} }
.tb-dashboard-action-panel { .tb-dashboard-action-panel {
@ -161,14 +162,14 @@ tb-dashboard-toolbar {
} }
@media #{$mat-lt-md} { @media #{$mat-lt-md} {
padding-left: 12px; // padding-left: 12px;
} }
@media #{$mat-xs} { @media #{$mat-xs} {
gap: 3px; gap: 3px;
padding-left: 0; padding-left: 0;
&.tb-left-panel { &.tb-left-panel {
padding-left: 12px; // padding-left: 12px;
} }
} }
@ -198,14 +199,14 @@ tb-dashboard-toolbar {
&.tb-left-panel { &.tb-left-panel {
place-content: center flex-end; place-content: center flex-end;
} }
height: 46px; height: $toolbar-height;
} }
> div { > div {
height: $half-mobile-toolbar-height; height: $half-mobile-toolbar-height;
@media #{$mat-gt-sm} { @media #{$mat-gt-sm} {
height: 46px; height: $toolbar-height;
} }
} }

19
ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.html

@ -15,11 +15,14 @@
limitations under the License. limitations under the License.
--> -->
<mat-select class="default-state-controller" [class.!hidden]="!displayStateSelection()" <div class="flex flex-row items-center justify-start gap-2">
[(ngModel)]="stateObject[0].id" (ngModelChange)="selectedStateIdChanged()"> <span class='state-divider'> > </span>
@for (stateKv of states | keyvalue; track stateKv) { <mat-select class="default-state-controller" [class.!hidden]="!displayStateSelection()"
<mat-option [value]="stateKv.key"> [(ngModel)]="stateObject[0].id" (ngModelChange)="selectedStateIdChanged()">
{{getStateName(stateKv.key, stateKv.value)}} @for (stateKv of states | keyvalue; track stateKv) {
</mat-option> <mat-option [value]="stateKv.key">
} {{getStateName(stateKv.key, stateKv.value)}}
</mat-select> </mat-option>
}
</mat-select>
</div>

30
ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.scss

@ -16,13 +16,41 @@
:host { :host {
pointer-events: all; pointer-events: all;
width: min-content; //for Safari width: min-content; //for Safari
.state-divider {
display: none;
padding: 0;
width: 20px;
min-width: 20px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
}
:host-context(.tb-breadcrumb) {
.state-divider {
display: block;
}
}
}
:host-context(.tb-breadcrumb) {
::ng-deep {
.mat-mdc-select.default-state-controller {
--mat-select-trigger-text-size: 16px;
--mat-select-enabled-trigger-text-color: rgba(0, 0, 0, 0.76);
}
}
} }
:host ::ng-deep { :host ::ng-deep {
.mat-mdc-select.default-state-controller { .mat-mdc-select.default-state-controller {
--mat-select-trigger-text-size: 14px;
.mat-mdc-select-value { .mat-mdc-select-value {
max-width: 200px; max-width: 200px;
font-size: 14px;
} }
.mat-mdc-select-arrow { .mat-mdc-select-arrow {
width: 24px; width: 24px;

41
ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.html

@ -16,29 +16,34 @@
--> -->
<div class="entity-state-controller"> <div class="entity-state-controller">
@if (!isMobile || stateObject.length === 1) { @if (!isMobile) {
<div class="flex flex-row items-center justify-start"> <div class="flex flex-row items-center justify-start gap-2">
@for (state of stateObject; track state; let i = $index; let isLast = $last) { @for (state of stateObject; track state; let i = $index; let isLast = $last;) {
@if ((breadcrumbs$ | async).length > 1 || i > 0) {
<span class='state-divider' [class.last-divider]="isLast"> > </span>
}
<span class="state-entry" <span class="state-entry"
[style.font-weight]="isLast ? 'bold' : 'normal'" [class.last-entry]="isLast"
[style.cursor]="isLast ? 'default' : 'pointer'" (click)="navigatePrevState(i)"> (click)="navigatePrevState(i)">
{{getStateName(i)}} {{getStateName(i)}}
<span class='state-divider' [class.!hidden]="isLast"> > </span>
</span> </span>
} }
</div> </div>
} }
@if (isMobile && stateObject.length > 1) { @if (isMobile) {
<mat-select @if (stateObject.length > 1) {
[(ngModel)]="selectedStateIndex" (ngModelChange)="selectedStateIndexChanged()"> <mat-select
@for (state of stateObject; track state; let i = $index) { [(ngModel)]="selectedStateIndex" (ngModelChange)="selectedStateIndexChanged()">
<mat-option [value]="i"> @for (state of stateObject; track state; let i = $index) {
{{getStateName(i)}} <mat-option [value]="i">
</mat-option> {{getStateName(i)}}
} </mat-option>
</mat-select> }
</mat-select>
} @else {
<h1 (click)="navigatePrevState(0)">
{{getStateName(0)}}
</h1>
}
} }
</div> </div>

43
ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.scss

@ -14,36 +14,67 @@
* limitations under the License. * limitations under the License.
*/ */
:host { :host {
display: block;
overflow: hidden; overflow: hidden;
.entity-state-controller { .entity-state-controller {
color: rgba(0,0,0,0.76);
.state-divider { .state-divider {
padding: 0 15px; padding: 0;
width: 20px;
text-align: center;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
pointer-events: none; pointer-events: none;
opacity: .75;
&.last-divider {
opacity: 1;
}
} }
.state-entry { .state-entry {
pointer-events: all; pointer-events: all;
overflow: hidden; overflow: hidden;
font-size: 18px; font-size: 16px;
font-weight: 400;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: inherit;
outline: none; outline: none;
cursor: pointer;
&:not(.last-entry) {
opacity: .75;
transition: opacity .35s;
&:hover,
&:focus {
opacity: 1;
}
}
&.last-entry {
cursor: default;
}
}
h1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 24px;
line-height: 32px;
font-weight: 400;
} }
} }
} }
:host ::ng-deep { :host ::ng-deep {
.mat-mdc-select { .mat-mdc-select {
--mat-select-trigger-text-size: 24px;
--mat-select-trigger-text-line-height: 32px;
--mat-select-trigger-text-weight: 400;
--mat-select-enabled-trigger-text-color: rgba(0,0,0,0.76);
.mat-mdc-select-value { .mat-mdc-select-value {
max-width: 200px; max-width: 200px;
.mat-mdc-select-value-text {
font-size: 18px;
font-weight: 700;
}
} }
.mat-mdc-select-arrow { .mat-mdc-select-arrow {
width: 24px; width: 24px;

6
ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts

@ -29,6 +29,7 @@ import { EntityService } from '@core/http/entity.service';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { map, tap } from 'rxjs/operators'; import { map, tap } from 'rxjs/operators';
import { MobileService } from '@core/services/mobile.service'; import { MobileService } from '@core/services/mobile.service';
import { BreadcrumbService } from '@core/services/breadcrumb.service';
@Component({ @Component({
selector: 'tb-entity-state-controller', selector: 'tb-entity-state-controller',
@ -38,6 +39,8 @@ import { MobileService } from '@core/services/mobile.service';
}) })
export class EntityStateControllerComponent extends StateControllerComponent implements OnInit, OnDestroy { export class EntityStateControllerComponent extends StateControllerComponent implements OnInit, OnDestroy {
breadcrumbs$ = this.breadcrumbService.breadcrumbs$;
selectedStateIndex = -1; selectedStateIndex = -1;
constructor(protected router: Router, constructor(protected router: Router,
@ -47,7 +50,8 @@ export class EntityStateControllerComponent extends StateControllerComponent imp
private utils: UtilsService, private utils: UtilsService,
private entityService: EntityService, private entityService: EntityService,
private mobileService: MobileService, private mobileService: MobileService,
private dashboardUtils: DashboardUtilsService) { private dashboardUtils: DashboardUtilsService,
private breadcrumbService: BreadcrumbService) {
super(router, route, ngZone, statesControllerService); super(router, route, ngZone, statesControllerService);
} }

1
ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.scss

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
:host { :host {
pointer-events: auto;
&:has(section) { &:has(section) {
height: 100%; height: 100%;
} }

31
ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.ts

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, OnDestroy } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { GitHubService } from '@core/http/git-hub.service'; import { GitHubService } from '@core/http/git-hub.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { selectAuthUser, selectIsAuthenticated } from '@core/auth/auth.selectors'; import { selectAuthUser, selectIsAuthenticated } from '@core/auth/auth.selectors';
@ -32,18 +32,27 @@ const SETTINGS_KEY = 'HIDE_GITHUB_STAR_BUTTON';
styleUrl: './github-badge.component.scss', styleUrl: './github-badge.component.scss',
standalone: false standalone: false
}) })
export class GithubBadgeComponent implements OnDestroy { export class GithubBadgeComponent implements OnInit, OnDestroy {
get hideGithubBadge(): boolean {
return this.hide;
}
githubStar = 0; githubStar = 0;
private hide = false;
private stopWatch$ = new Subject<void>(); private stopWatch$ = new Subject<void>();
constructor(private gitHubService: GitHubService, constructor(private gitHubService: GitHubService,
private localStorageService: LocalStorageService, private localStorageService: LocalStorageService,
private store: Store<AppState>,) { private store: Store<AppState>,
const hide = this.localStorageService.getItem(SETTINGS_KEY) ?? false; private cd: ChangeDetectorRef) {
}
ngOnInit() {
this.hide = this.localStorageService.getItem(SETTINGS_KEY) ?? false;
if (!hide) { if (!this.hide) {
this.store.select(selectIsAuthenticated).pipe( this.store.select(selectIsAuthenticated).pipe(
filter((data) => data), filter((data) => data),
switchMap(() => this.store.select(selectAuthUser).pipe(take(1))), switchMap(() => this.store.select(selectAuthUser).pipe(take(1))),
@ -60,21 +69,25 @@ export class GithubBadgeComponent implements OnDestroy {
} else { } else {
this.githubStar = 0 this.githubStar = 0
} }
this.cd.detectChanges();
}); });
} }
} }
ngOnDestroy() {
this.stopWatch$.next();
this.stopWatch$.complete();
}
hideGithubStar($event: Event) { hideGithubStar($event: Event) {
$event?.stopPropagation(); $event?.stopPropagation();
this.localStorageService.setItem(SETTINGS_KEY, true); this.localStorageService.setItem(SETTINGS_KEY, true);
this.hide = true;
this.githubStar = 0; this.githubStar = 0;
this.stopWatch$.next(); this.stopWatch$.next();
this.stopWatch$.complete(); this.stopWatch$.complete();
this.cd.detectChanges();
} }
ngOnDestroy() {
this.stopWatch$.next();
this.stopWatch$.complete();
}
} }

35
ui-ngx/src/app/modules/home/components/github-badge/github-badge.module.ts

@ -0,0 +1,35 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@app/shared/shared.module';
import { GithubBadgeComponent } from '@home/components/github-badge/github-badge.component';
@NgModule({
declarations:
[
GithubBadgeComponent
],
imports: [
CommonModule,
SharedModule
],
exports: [
GithubBadgeComponent
]
})
export class GithubBadgeModule { }

6
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -202,6 +202,8 @@ import { ApiKeyGeneratedDialogComponent } from '@home/components/api-key/api-key
import { ApiKeysTableDialogComponent } from '@home/components/api-key/api-keys-table-dialog.component'; import { ApiKeysTableDialogComponent } from '@home/components/api-key/api-keys-table-dialog.component';
import { AuditLogFilterComponent } from "@home/components/audit-log/audit-log-filter.component"; import { AuditLogFilterComponent } from "@home/components/audit-log/audit-log-filter.component";
import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; import { EventsDialogComponent } from '@home/dialogs/events-dialog.component';
import { NotificationBellModule } from '@home/components/notification/notification-bell.module';
import { GithubBadgeModule } from '@home/components/github-badge/github-badge.module';
@NgModule({ @NgModule({
declarations: declarations:
@ -374,7 +376,9 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component';
StatesControllerModule, StatesControllerModule,
DeviceCredentialsModule, DeviceCredentialsModule,
DeviceProfileCommonModule, DeviceProfileCommonModule,
EntityDebugSettingsButtonComponent EntityDebugSettingsButtonComponent,
NotificationBellModule,
GithubBadgeModule
], ],
exports: [ exports: [
RouterTabsComponent, RouterTabsComponent,

38
ui-ngx/src/app/modules/home/components/notification/notification-bell.module.ts

@ -0,0 +1,38 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@app/shared/shared.module';
import { NotificationBellComponent } from '@home/components/notification/notification-bell.component';
import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component';
@NgModule({
declarations:
[
NotificationBellComponent,
ShowNotificationPopoverComponent
],
imports: [
CommonModule,
SharedModule
],
exports: [
NotificationBellComponent,
ShowNotificationPopoverComponent
]
})
export class NotificationBellModule { }

93
ui-ngx/src/app/modules/home/home.component.html

@ -48,54 +48,59 @@
<tb-user-menu [collapsed]="menuCollapsed()" (menuClicked)="sidenavClicked()"></tb-user-menu> <tb-user-menu [collapsed]="menuCollapsed()" (menuClicked)="sidenavClicked()"></tb-user-menu>
</mat-toolbar> </mat-toolbar>
</mat-sidenav> </mat-sidenav>
<mat-sidenav-content class="tb-site-content"> <mat-sidenav-content class="tb-site-content" [class.tb-force-fullscreen]="forceFullscreen">
<div class="flex flex-col" role="main" style="height: 100%;"> <div class="flex flex-col" role="main" style="height: 100%;">
<mat-toolbar class="tb-primary-toolbar flex flex-row"> @if (!hideMainToolbar) {
<button mat-icon-button id="main" <mat-toolbar class="tb-primary-toolbar flex flex-row">
[class.!hidden]="forceFullscreen || displaySearchMode()" <button mat-icon-button id="main"
class="gt-sm:!hidden" (click)="sidenav.toggle()"> [class.!hidden]="forceFullscreen || displaySearchMode()"
<mat-icon class="material-icons">menu</mat-icon> class="gt-sm:!hidden" (click)="sidenav.toggle()">
</button> <mat-icon class="material-icons">menu</mat-icon>
<button mat-icon-button </button>
[class.!hidden]="!forceFullscreen || displaySearchMode" <button mat-icon-button
(click)="goBack()"> [class.!hidden]="!forceFullscreen || displaySearchMode"
<mat-icon class="material-icons">arrow_back</mat-icon> (click)="goBack()">
</button> <mat-icon class="material-icons">arrow_back</mat-icon>
<button mat-icon-button </button>
[class.!hidden]="!displaySearchMode()" <button mat-icon-button
(click)="closeSearch()"> [class.!hidden]="!displaySearchMode()"
<mat-icon class="material-icons">arrow_back</mat-icon> (click)="closeSearch()">
</button> <mat-icon class="material-icons">arrow_back</mat-icon>
<tb-breadcrumb </button>
[class.!hidden]="displaySearchMode()" <tb-breadcrumb
class="mat-toolbar-tools flex-1">
</tb-breadcrumb>
<div [class.!hidden]="!displaySearchMode()" class="flex flex-1 flex-row">
<mat-form-field class="tb-appearance-transparent flex-1">
<input #searchInput matInput
[formControl]="textSearch"
placeholder="{{ 'common.enter-search' | translate }}"/>
</mat-form-field>
</div>
<button [class.!hidden]="!searchEnabled"
mat-icon-button (click)="openSearch()">
<mat-icon class="material-icons">search</mat-icon>
</button>
<tb-github-badge class="lt-md:!hidden"></tb-github-badge>
@if (fullscreenEnabled) {
<button
[class.!hidden]="displaySearchMode()" [class.!hidden]="displaySearchMode()"
class="lt-md:!hidden" class="mat-toolbar-tools flex-1">
mat-icon-button (click)="toggleFullscreen()"> </tb-breadcrumb>
<mat-icon class="material-icons">{{ isFullscreen() ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon> <div [class.!hidden]="!displaySearchMode()" class="flex flex-1 flex-row">
<mat-form-field class="tb-appearance-transparent flex-1">
<input #searchInput matInput
[formControl]="textSearch"
placeholder="{{ 'common.enter-search' | translate }}"/>
</mat-form-field>
</div>
<button [class.!hidden]="!searchEnabled"
mat-icon-button (click)="openSearch()">
<mat-icon class="material-icons">search</mat-icon>
</button> </button>
<tb-github-badge class="lt-md:!hidden"></tb-github-badge>
@if (fullscreenEnabled) {
<button
[class.!hidden]="displaySearchMode()"
class="lt-md:!hidden"
mat-icon-button (click)="toggleFullscreen()">
<mat-icon class="material-icons">{{ isFullscreen() ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
}
<tb-notification-bell></tb-notification-bell>
@if (forceFullscreen) {
<tb-user-menu></tb-user-menu>
}
</mat-toolbar>
@if (!hideLoadingBar && (isLoading$ | async)) {
<mat-progress-bar color="warn" style="z-index: 10; margin-bottom: -4px; width: 100%;" mode="indeterminate"
>
</mat-progress-bar>
} }
<tb-notification-bell></tb-notification-bell>
</mat-toolbar>
@if (!hideLoadingBar && (isLoading$ | async)) {
<mat-progress-bar color="warn" style="z-index: 10; margin-bottom: -4px; width: 100%;" mode="indeterminate"
>
</mat-progress-bar>
} }
<div #mainContent tb-toast class="tb-main-content flex flex-1 flex-col"> <div #mainContent tb-toast class="tb-main-content flex flex-1 flex-col">
<router-outlet (activate)="activeComponentChanged($event)"></router-outlet> <router-outlet (activate)="activeComponentChanged($event)"></router-outlet>

2
ui-ngx/src/app/modules/home/home.component.scss

@ -32,7 +32,7 @@
transition: opacity $sidenav-anim-timing; transition: opacity $sidenav-anim-timing;
} }
} }
mat-sidenav-content.tb-site-content { mat-sidenav-content.tb-site-content:not(.tb-force-fullscreen) {
margin-left: 250px !important; margin-left: 250px !important;
transition: margin-left $sidenav-anim-timing; transition: margin-left $sidenav-anim-timing;
} }

28
ui-ngx/src/app/modules/home/home.component.ts

@ -18,14 +18,14 @@ import {
AfterViewInit, AfterViewInit,
Component, Component,
computed, computed,
ElementRef, ElementRef, EventEmitter,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit, OnInit,
signal, signal,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { skip, startWith, Subject } from 'rxjs'; import { skip, startWith, Subject, Subscription } from 'rxjs';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { debounceTime, distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, take, takeUntil } from 'rxjs/operators';
@ -44,6 +44,7 @@ import { RouterTabsComponent } from '@home/components/router-tabs.component';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { isDefinedAndNotNull } from '@core/utils'; import { isDefinedAndNotNull } from '@core/utils';
import { ActionPreferencesPutUserSettings } from '@core/auth/auth.actions'; import { ActionPreferencesPutUserSettings } from '@core/auth/auth.actions';
import { isMainToolbarComponent } from '@home/models/main-toolbar.models';
@Component({ @Component({
selector: 'tb-home', selector: 'tb-home',
@ -85,6 +86,10 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
hideLoadingBar = false; hideLoadingBar = false;
hideMainToolbar = false;
private toggleSideBarSubscription: Subscription;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
@ -125,6 +130,9 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
} }
ngOnDestroy() { ngOnDestroy() {
if (this.toggleSideBarSubscription) {
this.toggleSideBarSubscription.unsubscribe();
}
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
} }
@ -197,6 +205,22 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
this.searchEnabled = false; this.searchEnabled = false;
this.searchableComponent = null; this.searchableComponent = null;
} }
if (this.toggleSideBarSubscription) {
this.toggleSideBarSubscription.unsubscribe();
this.toggleSideBarSubscription = null;
}
this.hideMainToolbar = false;
if (isMainToolbarComponent(this.activeComponent)) {
this.hideMainToolbar = this.activeComponent.hideMainToolbar;
this.toggleSideBarSubscription = this.activeComponent.toggleSideBar.subscribe(
() => {
this.sidenav.toggle();
}
);
}
} }
displaySearchMode(): boolean { displaySearchMode(): boolean {

12
ui-ngx/src/app/modules/home/home.module.ts

@ -23,9 +23,8 @@ import { SharedModule } from '@app/shared/shared.module';
import { MenuLinkComponent } from '@modules/home/menu/menu-link.component'; import { MenuLinkComponent } from '@modules/home/menu/menu-link.component';
import { MenuToggleComponent } from '@modules/home/menu/menu-toggle.component'; import { MenuToggleComponent } from '@modules/home/menu/menu-toggle.component';
import { SideMenuComponent } from '@modules/home/menu/side-menu.component'; import { SideMenuComponent } from '@modules/home/menu/side-menu.component';
import { GithubBadgeComponent } from '@home/components/github-badge/github-badge.component'; import { NotificationBellModule } from '@home/components/notification/notification-bell.module';
import { NotificationBellComponent } from '@home/components/notification/notification-bell.component'; import { GithubBadgeModule } from '@home/components/github-badge/github-badge.module';
import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component';
@NgModule({ @NgModule({
declarations: declarations:
@ -33,14 +32,13 @@ import { ShowNotificationPopoverComponent } from '@home/components/notification/
HomeComponent, HomeComponent,
MenuLinkComponent, MenuLinkComponent,
MenuToggleComponent, MenuToggleComponent,
SideMenuComponent, SideMenuComponent
GithubBadgeComponent,
NotificationBellComponent,
ShowNotificationPopoverComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
SharedModule, SharedModule,
NotificationBellModule,
GithubBadgeModule,
HomeRoutingModule HomeRoutingModule
] ]
}) })

28
ui-ngx/src/app/modules/home/models/main-toolbar.models.ts

@ -0,0 +1,28 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { EventEmitter } from '@angular/core';
import { isObservable } from 'rxjs';
export interface MainToolbarComponent {
hideMainToolbar: boolean;
toggleSideBar: EventEmitter<void>;
}
export const isMainToolbarComponent = (cmp: any): cmp is MainToolbarComponent => {
const mainToolbarCmp = (cmp as MainToolbarComponent);
return isObservable(mainToolbarCmp.toggleSideBar);
};

3
ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts

@ -26,7 +26,7 @@ import { AssetsTableConfigResolver } from '../asset/assets-table-config.resolver
import { DashboardsTableConfigResolver } from '@modules/home/pages/dashboard/dashboards-table-config.resolver'; import { DashboardsTableConfigResolver } from '@modules/home/pages/dashboard/dashboards-table-config.resolver';
import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component'; import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component';
import { BreadCrumbConfig } from '@shared/components/breadcrumb'; import { BreadCrumbConfig } from '@shared/components/breadcrumb';
import { dashboardBreadcumbLabelFunction, DashboardResolver } from '@home/pages/dashboard/dashboard-routing.module'; import { DashboardResolver } from '@home/pages/dashboard/dashboard-routing.module';
import { EdgesTableConfigResolver } from '@home/pages/edge/edges-table-config.resolver'; import { EdgesTableConfigResolver } from '@home/pages/edge/edges-table-config.resolver';
import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
@ -254,7 +254,6 @@ const routes: Routes = [
canDeactivate: [ConfirmOnExitGuard], canDeactivate: [ConfirmOnExitGuard],
data: { data: {
breadcrumb: { breadcrumb: {
labelFunction: dashboardBreadcumbLabelFunction,
icon: 'dashboard' icon: 'dashboard'
} as BreadCrumbConfig<DashboardPageComponent>, } as BreadCrumbConfig<DashboardPageComponent>,
auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER],

6
ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts

@ -21,7 +21,7 @@ import { EntitiesTableComponent } from '../../components/entity/entities-table.c
import { Authority } from '@shared/models/authority.enum'; import { Authority } from '@shared/models/authority.enum';
import { DashboardsTableConfigResolver } from './dashboards-table-config.resolver'; import { DashboardsTableConfigResolver } from './dashboards-table-config.resolver';
import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component'; import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component';
import { BreadCrumbConfig, BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; import { BreadCrumbConfig } from '@shared/components/breadcrumb';
import { mergeMap, Observable, of } from 'rxjs'; import { mergeMap, Observable, of } from 'rxjs';
import { Dashboard } from '@app/shared/models/dashboard.models'; import { Dashboard } from '@app/shared/models/dashboard.models';
import { DashboardService } from '@core/http/dashboard.service'; import { DashboardService } from '@core/http/dashboard.service';
@ -59,9 +59,6 @@ export class DashboardResolver {
} }
} }
export const dashboardBreadcumbLabelFunction: BreadCrumbLabelFunction<DashboardPageComponent>
= ((route, translate, component) => component.dashboard.title);
const routes: Routes = [ const routes: Routes = [
{ {
path: 'dashboards', path: 'dashboards',
@ -89,7 +86,6 @@ const routes: Routes = [
canDeactivate: [ConfirmOnExitGuard], canDeactivate: [ConfirmOnExitGuard],
data: { data: {
breadcrumb: { breadcrumb: {
labelFunction: dashboardBreadcumbLabelFunction,
icon: 'dashboard' icon: 'dashboard'
} as BreadCrumbConfig<DashboardPageComponent>, } as BreadCrumbConfig<DashboardPageComponent>,
auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER],

3
ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts

@ -25,7 +25,7 @@ import { EntityViewsTableConfigResolver } from '@home/pages/entity-view/entity-v
import { DashboardsTableConfigResolver } from '@home/pages/dashboard/dashboards-table-config.resolver'; import { DashboardsTableConfigResolver } from '@home/pages/dashboard/dashboards-table-config.resolver';
import { RuleChainsTableConfigResolver } from '@home/pages/rulechain/rulechains-table-config.resolver'; import { RuleChainsTableConfigResolver } from '@home/pages/rulechain/rulechains-table-config.resolver';
import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component'; import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component';
import { dashboardBreadcumbLabelFunction, DashboardResolver } from '@home/pages/dashboard/dashboard-routing.module'; import { DashboardResolver } from '@home/pages/dashboard/dashboard-routing.module';
import { BreadCrumbConfig } from '@shared/components/breadcrumb'; import { BreadCrumbConfig } from '@shared/components/breadcrumb';
import { RuleChainPageComponent } from '@home/pages/rulechain/rulechain-page.component'; import { RuleChainPageComponent } from '@home/pages/rulechain/rulechain-page.component';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
@ -243,7 +243,6 @@ const routes: Routes = [
canDeactivate: [ConfirmOnExitGuard], canDeactivate: [ConfirmOnExitGuard],
data: { data: {
breadcrumb: { breadcrumb: {
labelFunction: dashboardBreadcumbLabelFunction,
icon: 'dashboard' icon: 'dashboard'
} as BreadCrumbConfig<DashboardPageComponent>, } as BreadCrumbConfig<DashboardPageComponent>,
auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER],

8
ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts

@ -88,9 +88,11 @@ export const widgetsBundleWidgetsBreadcumbLabelFunction: BreadCrumbLabelFunction
route.data.widgetsBundle.title); route.data.widgetsBundle.title);
export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction<WidgetEditorComponent> = export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction<WidgetEditorComponent> =
((route, translate, component) => ((route, translate, component) => {
component?.widget?.widgetName ? const widget = component?.widget ?? route.data?.widgetEditorData?.widget;
(component.widget.widgetName + (component.widget.deprecated ? ` (${translate.instant('widget.deprecated')})` : '')) : ''); return widget?.widgetName ?
(widget.widgetName + (widget.deprecated ? ` (${translate.instant('widget.deprecated')})` : '')) : '';
});
const widgetEditorRouter: Routes = [ const widgetEditorRouter: Routes = [
{ {

77
ui-ngx/src/app/shared/components/breadcrumb.component.html

@ -16,33 +16,64 @@
--> -->
<div class="tb-breadcrumb flex flex-1 flex-row gap-2"> <div class="tb-breadcrumb flex flex-1 flex-row gap-2">
@if (lastBreadcrumb$ | async; as breadcrumb) { @if (componentBreadcrumbsTpl && (lastBreadcrumb$ | async) === null) {
<h1 class="flex-1 gt-sm:!hidden"> <div class="min-w-0 flex-1">
<ng-container <ng-container
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}"> *ngTemplateOutlet="componentBreadcrumbsTpl">
</ng-container> </ng-container>
</h1> </div>
} } @else {
@for (breadcrumb of breadcrumbs$ | async; track trackByBreadcrumbs; let isLast = $last; let i = $index; let total = $count;) { @if (lastBreadcrumb$ | async; as breadcrumb) {
<span class="flex flex-row gap-2 lt-md:!hidden"> @if (componentBreadcrumbsTpl) {
@switch (isLast) { <div class="flex-1 gt-sm:!hidden">
@case (false) { <ng-container
<a [routerLink]="breadcrumb.link" [queryParams]="breadcrumb.queryParams"> *ngTemplateOutlet="componentBreadcrumbsTpl">
<ng-container </ng-container>
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}"> </div>
</ng-container> } @else {
</a> <h1 class="flex-1 gt-sm:!hidden">
} <ng-container
@case (true) { *ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}">
<span> </ng-container>
<ng-container </h1>
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}"> }
</ng-container> }
</span> <div class="flex flex-1 flex-row items-center gap-2 overflow-hidden lt-md:!hidden">
@for (breadcrumb of breadcrumbs$ | async; track breadcrumb.id; let isLast = $last; let i = $index;) {
@switch (isLast) {
@case (false) {
@if (i > 0) {
<span class="divider"> > </span>
}
<span class="flex flex-row gap-2">
<a [routerLink]="breadcrumb.link" [queryParams]="breadcrumb.queryParams">
<ng-container
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}">
</ng-container>
</a>
</span>
}
@case (true) {
@if (componentBreadcrumbsTpl) {
<span>
<ng-container
*ngTemplateOutlet="componentBreadcrumbsTpl">
</ng-container>
</span>
} @else {
@if (i > 0) {
<span class="divider last-divider"> > </span>
}
<span>
<ng-container
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}">
</ng-container>
</span>
}
}
} }
} }
<span class="divider" [class.last-divider]="i === total - 2" [class.!hidden]="isLast"> > </span> </div>
</span>
} }
</div> </div>
<ng-template #breadcrumbTpl let-breadcrumb="breadcrumb"> <ng-template #breadcrumbTpl let-breadcrumb="breadcrumb">

1
ui-ngx/src/app/shared/components/breadcrumb.component.scss

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
:host { :host {
pointer-events: auto;
min-width: 0; min-width: 0;
.tb-breadcrumb { .tb-breadcrumb {

128
ui-ngx/src/app/shared/components/breadcrumb.component.ts

@ -14,18 +14,19 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import {
import { BehaviorSubject, Subject, Subscription } from 'rxjs'; ChangeDetectionStrategy,
import { BreadCrumb, BreadCrumbConfig } from './breadcrumb'; ChangeDetectorRef,
import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; Component,
import { distinctUntilChanged, filter, first, map, switchMap } from 'rxjs/operators'; Input, OnDestroy,
import { TranslateService } from '@ngx-translate/core'; OnInit,
import { guid } from '@core/utils'; TemplateRef
} from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { BreadCrumb } from './breadcrumb';
import { BroadcastService } from '@core/services/broadcast.service'; import { BroadcastService } from '@core/services/broadcast.service';
import { ActiveComponentService } from '@core/services/active-component.service';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { MenuSection, menuSectionMap } from '@core/services/menu.models'; import { BreadcrumbService } from '@core/services/breadcrumb.service';
import { MenuService } from '@core/services/menu.service';
@Component({ @Component({
selector: 'tb-breadcrumb', selector: 'tb-breadcrumb',
@ -36,116 +37,29 @@ import { MenuService } from '@core/services/menu.service';
}) })
export class BreadcrumbComponent implements OnInit, OnDestroy { export class BreadcrumbComponent implements OnInit, OnDestroy {
activeComponentValue: any; @Input()
updateBreadcrumbsSubscription: Subscription = null; componentBreadcrumbsTpl?: TemplateRef<any>;
setActiveComponent(activeComponent: any) { breadcrumbs$: Observable<BreadCrumb[]> = this.breadcrumbService.breadcrumbs$;
if (this.updateBreadcrumbsSubscription) { lastBreadcrumb$: Observable<BreadCrumb> = this.breadcrumbService.lastBreadcrumb$;
this.updateBreadcrumbsSubscription.unsubscribe();
this.updateBreadcrumbsSubscription = null;
}
this.activeComponentValue = activeComponent;
if (this.activeComponentValue && this.activeComponentValue.updateBreadcrumbs) {
this.updateBreadcrumbsSubscription = this.activeComponentValue.updateBreadcrumbs.subscribe(() => {
this.menuService.availableMenuSections().pipe(first()).subscribe((sections) => {
this.breadcrumbs$.next(this.buildBreadCrumbs(this.activatedRoute.snapshot, sections));
});
});
}
}
breadcrumbs$: Subject<Array<BreadCrumb>> = new BehaviorSubject<Array<BreadCrumb>>([]);
routerEventsSubscription = this.router.events.pipe( private updateBreadcrumbsSubscription: Subscription;
filter((event) => event instanceof NavigationEnd ),
distinctUntilChanged(),
switchMap(() => this.menuService.availableMenuSections().pipe(first())),
map( (sections) => this.buildBreadCrumbs(this.activatedRoute.snapshot, sections) )
).subscribe(breadcrumns => this.breadcrumbs$.next(breadcrumns) );
activeComponentSubscription = this.activeComponentService.onActiveComponentChanged().subscribe(comp => this.setActiveComponent(comp)); constructor(private broadcast: BroadcastService,
private breadcrumbService: BreadcrumbService,
lastBreadcrumb$ = this.breadcrumbs$.pipe(
map( breadcrumbs => breadcrumbs[breadcrumbs.length - 1])
);
constructor(private router: Router,
private activatedRoute: ActivatedRoute,
private broadcast: BroadcastService,
private activeComponentService: ActiveComponentService,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private translate: TranslateService,
private menuService: MenuService,
public utils: UtilsService) { public utils: UtilsService) {
} }
ngOnInit(): void { ngOnInit(): void {
this.broadcast.on('updateBreadcrumb', () => { this.updateBreadcrumbsSubscription = this.broadcast.on('updateBreadcrumb', () => {
this.cd.markForCheck(); this.cd.markForCheck();
}); });
this.setActiveComponent(this.activeComponentService.getCurrentActiveComponent());
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.routerEventsSubscription) { if (this.updateBreadcrumbsSubscription) {
this.routerEventsSubscription.unsubscribe(); this.updateBreadcrumbsSubscription.unsubscribe();
}
if (this.activeComponentSubscription) {
this.activeComponentSubscription.unsubscribe();
}
}
private lastChild(route: ActivatedRouteSnapshot) {
let child = route;
while (child.firstChild !== null) {
child = child.firstChild;
}
return child;
}
buildBreadCrumbs(route: ActivatedRouteSnapshot, availableMenuSections: MenuSection[],
breadcrumbs: Array<BreadCrumb> = [],
lastChild?: ActivatedRouteSnapshot): Array<BreadCrumb> {
if (!lastChild) {
lastChild = this.lastChild(route);
}
let newBreadcrumbs = breadcrumbs;
if (route.routeConfig && route.routeConfig.data) {
const breadcrumbConfig = route.routeConfig.data.breadcrumb as BreadCrumbConfig<any>;
if (breadcrumbConfig && !breadcrumbConfig.skip) {
let labelFunction: () => string;
let section: MenuSection = null;
if (breadcrumbConfig.menuId) {
section = availableMenuSections.find(menu => menu.id === breadcrumbConfig.menuId);
if (!section) {
section = menuSectionMap.get(breadcrumbConfig.menuId);
}
}
const label = section?.name || breadcrumbConfig.label || 'home.home';
const customTranslate = section?.customTranslate || false;
if (breadcrumbConfig.labelFunction) {
labelFunction = () => this.activeComponentValue ?
breadcrumbConfig.labelFunction(route, this.translate, this.activeComponentValue, lastChild.data) : label;
}
const link = [ route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/') ];
const breadcrumb = {
id: guid(),
label,
customTranslate,
labelFunction,
link,
queryParams: null
};
newBreadcrumbs = [...breadcrumbs, breadcrumb];
}
}
if (route.firstChild) {
return this.buildBreadCrumbs(route.firstChild, availableMenuSections, newBreadcrumbs, lastChild);
} }
return newBreadcrumbs;
}
trackByBreadcrumbs(_index: number, breadcrumb: BreadCrumb){
return breadcrumb.id;
} }
} }

9
ui-ngx/src/app/shared/components/user-menu.component.scss

@ -16,6 +16,15 @@
@import "constants"; @import "constants";
.tb-primary-toolbar, .tb-dashboard-action-panel {
tb-user-menu {
width: auto;
button.mat-mdc-button:not(.mat-mdc-icon-button).tb-user-menu {
padding: 12px 16px;
}
}
}
tb-user-menu { tb-user-menu {
width: 100%; width: 100%;

4
ui-ngx/src/theme.scss

@ -134,8 +134,8 @@ $tb-dark-theme: map_merge($tb-dark-theme, (_mat-system: $tb-dark-theme-system));
mat-fab-toolbar { mat-fab-toolbar {
.mat-fab-toolbar-background { .mat-fab-toolbar-background {
background: mat.m2-get-color-from-palette($background, app-bar); background: var(--mat-toolbar-container-background-color, var(--mat-sys-surface));
color: mat.m2-get-color-from-palette($foreground, text); color: var(--mat-toolbar-container-text-color, var(--mat-sys-on-surface));
} }
&.mat-primary { &.mat-primary {
.mat-fab-toolbar-background { .mat-fab-toolbar-background {

Loading…
Cancel
Save