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.
-->
<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
tb-fullscreen [fullscreenElement]="elRef.nativeElement" [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
@ -50,17 +64,22 @@
[dashboardCtrl]="this">
</tb-select-dashboard-breakpoint>
</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) {
<button class="gt-md:!hidden" mat-icon-button
matTooltip="{{'dashboard.manage-states' | translate}}"
@ -79,6 +98,13 @@
@if (showDashboardLogo()) {
<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 class="tb-dashboard-action-panel">
<button [class.!hidden]="!showCloseToolbar()" mat-icon-button class="close-action"
@ -97,12 +123,20 @@
(click)="toggleLayouts()">
<mat-icon>{{isRightLayoutOpened ? 'arrow_back' : 'menu'}}</mat-icon>
</button>
<button [class.!hidden]="hideFullscreenButton()" class="xs:!hidden" mat-icon-button
matTooltip="{{(isFullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="below"
(click)="isFullscreen = !isFullscreen">
<mat-icon>{{ isFullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
<div class="flex flex-row-reverse items-center lt-md:!hidden">
@if (!isEdit) {
<tb-notification-bell></tb-notification-bell>
}
<button [class.!hidden]="hideFullscreenButton()" mat-icon-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
matTooltip="{{'dashboard.export' | translate}}"
matTooltipPosition="below"
@ -191,7 +225,7 @@
{{ 'dashboard.settings' | translate }}
</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}}"
matTooltipPosition="below"
(click)="updateDashboardImage($event)">
@ -264,12 +298,17 @@
[aliasController]="dashboardCtx.aliasController">
</tb-aliases-entity-select>
@if (!isEdit && !widgetEditMode && !embedded && displayDashboardsSelect()) {
<tb-dashboard-select [(ngModel)]="currentDashboardId"
<tb-dashboard-select class="ml-2" [(ngModel)]="currentDashboardId"
(ngModelChange)="currentDashboardIdChanged(currentDashboardId)"
[customerId]="currentCustomerId"
[dashboardsScope]="currentDashboardScope">
</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>
</tb-dashboard-toolbar>
@ -350,7 +389,7 @@
@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">
<button [class.!hidden]="isEdit"
color="primary"
color="#fff"
mat-mini-fab
class="tb-btn-header tb-btn-edit"
(click)="toggleDashboardEditMode()"
@ -359,7 +398,7 @@
<mat-icon>edit</mat-icon>
</button>
<button [class.!hidden]="!isEdit"
color="primary"
color="#fff"
mat-mini-fab
class="tb-btn-header tb-btn-edit"
(click)="saveDashboard()"
@ -369,7 +408,7 @@
<mat-icon>done</mat-icon>
</button>
<button [class.!hidden]="!isEdit"
color="primary"
color="#fff"
mat-mini-fab
class="tb-btn-header tb-btn-edit"
(click)="toggleDashboardEditMode()"

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

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

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

@ -149,6 +149,7 @@ import {
MoveWidgetsDialogResult
} from '@home/components/dashboard-page/layout/move-widgets-dialog.component';
import { HttpStatusCode } from '@angular/common/http';
import { MainToolbarComponent } from '@home/models/main-toolbar.models';
// @dynamic
@Component({
@ -159,7 +160,7 @@ import { HttpStatusCode } from '@angular/common/http';
changeDetection: ChangeDetectionStrategy.OnPush,
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;
@ -267,7 +268,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
dashboardLogoLink = this.getDashboardLogoLink();
private dashboardLogoCache: SafeUrl;
private defaultDashboardLogo = 'assets/logo_title_white.svg';
private defaultDashboardLogo = 'assets/logo_title_black.svg';
private dashboardResize$: ResizeObserver;
@ -321,6 +322,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
updateBreadcrumbs = new EventEmitter();
hideMainToolbar = true;
toggleSideBar = new EventEmitter();
private rxSubscriptions = new Array<Subscription>();
get toolbarOpened(): boolean {
@ -867,6 +871,10 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
return this.authUser.authority === Authority.SYS_ADMIN;
}
public canEdit(): boolean {
return this.isTenantAdmin() || (this.isSystemAdmin() && this.widgetEditMode);
}
public exportDashboard($event: Event) {
if ($event) {
$event.preventDefault();
@ -1745,6 +1753,10 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
}
}
toggleSidenav() {
this.toggleSideBar.emit();
}
get showMainLayoutFiller(): boolean {
const layoutMaxWidth = this.dashboardUtils.getBreakpointInfoById(this.layouts.main.layoutCtx.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.
-->
<mat-fab-toolbar [isOpen]="toolbarOpened" color="primary" direction="left"
[class.is-fullscreen]="forceFullscreen"
[class.mat-elevation-z1]="forceFullscreen && toolbarOpened">
<mat-fab-toolbar [isOpen]="toolbarOpened" direction="left"
[class.is-fullscreen]="forceFullscreen">
<mat-fab-trigger>
<button mat-fab color="primary"
<button mat-fab color="#fff"
[matTooltip]="!toolbarOpened ? ('dashboard.open-toolbar' | translate) : ''"
matTooltipPosition="below"
(click)="onTriggerClick()">
<mat-icon>more_horiz</mat-icon>
</button>
</mat-fab-trigger>
<mat-toolbar color="primary">
<mat-toolbar>
<mat-fab-actions class="mat-toolbar-tools">
<ng-content></ng-content>
</mat-fab-actions>

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

@ -15,11 +15,11 @@
*/
@import "../../../../../scss/constants";
$toolbar-height: 50px !default;
$fullscreen-toolbar-height: 64px !default;
$mobile-toolbar-height: 80px !default;
$half-mobile-toolbar-height: 40px !default;
$mobile-toolbar-height-total: 84px !default;
$toolbar-height: 56px !default;
$fullscreen-toolbar-height: 56px !default;
$mobile-toolbar-height: 96px !default;
$half-mobile-toolbar-height: 48px !default;
$mobile-toolbar-height-total: 96px !default;
$mobile-app-toolbar-height: 40px !default;
@ -97,6 +97,7 @@ tb-dashboard-toolbar {
.mat-fab-toolbar-wrapper {
height: $mobile-toolbar-height-total;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
@media #{$mat-gt-sm} {
height: $toolbar-height;
@ -121,12 +122,12 @@ tb-dashboard-toolbar {
mat-fab-actions {
margin-top: 0;
padding: 0 16px !important;
font-size: 16px;
@media #{$mat-lt-md} {
height: $mobile-toolbar-height;
max-height: $mobile-toolbar-height;
padding: 0 8px !important;
}
.close-action {
@ -138,14 +139,14 @@ tb-dashboard-toolbar {
height: $mobile-toolbar-height;
@media #{$mat-gt-sm} {
height: 46px;
height: $toolbar-height;
}
.tb-dashboard-action-panels {
height: $mobile-toolbar-height;
@media #{$mat-gt-sm} {
height: 46px;
height: $toolbar-height;
}
.tb-dashboard-action-panel {
@ -161,14 +162,14 @@ tb-dashboard-toolbar {
}
@media #{$mat-lt-md} {
padding-left: 12px;
// padding-left: 12px;
}
@media #{$mat-xs} {
gap: 3px;
padding-left: 0;
&.tb-left-panel {
padding-left: 12px;
// padding-left: 12px;
}
}
@ -198,14 +199,14 @@ tb-dashboard-toolbar {
&.tb-left-panel {
place-content: center flex-end;
}
height: 46px;
height: $toolbar-height;
}
> div {
height: $half-mobile-toolbar-height;
@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.
-->
<mat-select class="default-state-controller" [class.!hidden]="!displayStateSelection()"
[(ngModel)]="stateObject[0].id" (ngModelChange)="selectedStateIdChanged()">
@for (stateKv of states | keyvalue; track stateKv) {
<mat-option [value]="stateKv.key">
{{getStateName(stateKv.key, stateKv.value)}}
</mat-option>
}
</mat-select>
<div class="flex flex-row items-center justify-start gap-2">
<span class='state-divider'> > </span>
<mat-select class="default-state-controller" [class.!hidden]="!displayStateSelection()"
[(ngModel)]="stateObject[0].id" (ngModelChange)="selectedStateIdChanged()">
@for (stateKv of states | keyvalue; track stateKv) {
<mat-option [value]="stateKv.key">
{{getStateName(stateKv.key, stateKv.value)}}
</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 {
pointer-events: all;
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 {
.mat-mdc-select.default-state-controller {
--mat-select-trigger-text-size: 14px;
.mat-mdc-select-value {
max-width: 200px;
font-size: 14px;
}
.mat-mdc-select-arrow {
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">
@if (!isMobile || stateObject.length === 1) {
<div class="flex flex-row items-center justify-start">
@for (state of stateObject; track state; let i = $index; let isLast = $last) {
@if (!isMobile) {
<div class="flex flex-row items-center justify-start gap-2">
@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"
[style.font-weight]="isLast ? 'bold' : 'normal'"
[style.cursor]="isLast ? 'default' : 'pointer'" (click)="navigatePrevState(i)">
[class.last-entry]="isLast"
(click)="navigatePrevState(i)">
{{getStateName(i)}}
<span class='state-divider' [class.!hidden]="isLast"> > </span>
</span>
}
</div>
}
@if (isMobile && stateObject.length > 1) {
<mat-select
[(ngModel)]="selectedStateIndex" (ngModelChange)="selectedStateIndexChanged()">
@for (state of stateObject; track state; let i = $index) {
<mat-option [value]="i">
{{getStateName(i)}}
</mat-option>
}
</mat-select>
@if (isMobile) {
@if (stateObject.length > 1) {
<mat-select
[(ngModel)]="selectedStateIndex" (ngModelChange)="selectedStateIndexChanged()">
@for (state of stateObject; track state; let i = $index) {
<mat-option [value]="i">
{{getStateName(i)}}
</mat-option>
}
</mat-select>
} @else {
<h1 (click)="navigatePrevState(0)">
{{getStateName(0)}}
</h1>
}
}
</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.
*/
:host {
display: block;
overflow: hidden;
.entity-state-controller {
color: rgba(0,0,0,0.76);
.state-divider {
padding: 0 15px;
padding: 0;
width: 20px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
opacity: .75;
&.last-divider {
opacity: 1;
}
}
.state-entry {
pointer-events: all;
overflow: hidden;
font-size: 18px;
font-size: 16px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
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 {
.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 {
max-width: 200px;
.mat-mdc-select-value-text {
font-size: 18px;
font-weight: 700;
}
}
.mat-mdc-select-arrow {
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 { map, tap } from 'rxjs/operators';
import { MobileService } from '@core/services/mobile.service';
import { BreadcrumbService } from '@core/services/breadcrumb.service';
@Component({
selector: 'tb-entity-state-controller',
@ -38,6 +39,8 @@ import { MobileService } from '@core/services/mobile.service';
})
export class EntityStateControllerComponent extends StateControllerComponent implements OnInit, OnDestroy {
breadcrumbs$ = this.breadcrumbService.breadcrumbs$;
selectedStateIndex = -1;
constructor(protected router: Router,
@ -47,7 +50,8 @@ export class EntityStateControllerComponent extends StateControllerComponent imp
private utils: UtilsService,
private entityService: EntityService,
private mobileService: MobileService,
private dashboardUtils: DashboardUtilsService) {
private dashboardUtils: DashboardUtilsService,
private breadcrumbService: BreadcrumbService) {
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.
*/
:host {
pointer-events: auto;
&:has(section) {
height: 100%;
}

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

@ -14,7 +14,7 @@
/// 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 { Store } from '@ngrx/store';
import { selectAuthUser, selectIsAuthenticated } from '@core/auth/auth.selectors';
@ -32,18 +32,27 @@ const SETTINGS_KEY = 'HIDE_GITHUB_STAR_BUTTON';
styleUrl: './github-badge.component.scss',
standalone: false
})
export class GithubBadgeComponent implements OnDestroy {
export class GithubBadgeComponent implements OnInit, OnDestroy {
get hideGithubBadge(): boolean {
return this.hide;
}
githubStar = 0;
private hide = false;
private stopWatch$ = new Subject<void>();
constructor(private gitHubService: GitHubService,
private localStorageService: LocalStorageService,
private store: Store<AppState>,) {
const hide = this.localStorageService.getItem(SETTINGS_KEY) ?? false;
private store: Store<AppState>,
private cd: ChangeDetectorRef) {
}
ngOnInit() {
this.hide = this.localStorageService.getItem(SETTINGS_KEY) ?? false;
if (!hide) {
if (!this.hide) {
this.store.select(selectIsAuthenticated).pipe(
filter((data) => data),
switchMap(() => this.store.select(selectAuthUser).pipe(take(1))),
@ -60,21 +69,25 @@ export class GithubBadgeComponent implements OnDestroy {
} else {
this.githubStar = 0
}
this.cd.detectChanges();
});
}
}
ngOnDestroy() {
this.stopWatch$.next();
this.stopWatch$.complete();
}
hideGithubStar($event: Event) {
$event?.stopPropagation();
this.localStorageService.setItem(SETTINGS_KEY, true);
this.hide = true;
this.githubStar = 0;
this.stopWatch$.next();
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 { AuditLogFilterComponent } from "@home/components/audit-log/audit-log-filter.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({
declarations:
@ -374,7 +376,9 @@ import { EventsDialogComponent } from '@home/dialogs/events-dialog.component';
StatesControllerModule,
DeviceCredentialsModule,
DeviceProfileCommonModule,
EntityDebugSettingsButtonComponent
EntityDebugSettingsButtonComponent,
NotificationBellModule,
GithubBadgeModule
],
exports: [
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>
</mat-toolbar>
</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%;">
<mat-toolbar class="tb-primary-toolbar flex flex-row">
<button mat-icon-button id="main"
[class.!hidden]="forceFullscreen || displaySearchMode()"
class="gt-sm:!hidden" (click)="sidenav.toggle()">
<mat-icon class="material-icons">menu</mat-icon>
</button>
<button mat-icon-button
[class.!hidden]="!forceFullscreen || displaySearchMode"
(click)="goBack()">
<mat-icon class="material-icons">arrow_back</mat-icon>
</button>
<button mat-icon-button
[class.!hidden]="!displaySearchMode()"
(click)="closeSearch()">
<mat-icon class="material-icons">arrow_back</mat-icon>
</button>
<tb-breadcrumb
[class.!hidden]="displaySearchMode()"
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
@if (!hideMainToolbar) {
<mat-toolbar class="tb-primary-toolbar flex flex-row">
<button mat-icon-button id="main"
[class.!hidden]="forceFullscreen || displaySearchMode()"
class="gt-sm:!hidden" (click)="sidenav.toggle()">
<mat-icon class="material-icons">menu</mat-icon>
</button>
<button mat-icon-button
[class.!hidden]="!forceFullscreen || displaySearchMode"
(click)="goBack()">
<mat-icon class="material-icons">arrow_back</mat-icon>
</button>
<button mat-icon-button
[class.!hidden]="!displaySearchMode()"
(click)="closeSearch()">
<mat-icon class="material-icons">arrow_back</mat-icon>
</button>
<tb-breadcrumb
[class.!hidden]="displaySearchMode()"
class="lt-md:!hidden"
mat-icon-button (click)="toggleFullscreen()">
<mat-icon class="material-icons">{{ isFullscreen() ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
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="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">
<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;
}
}
mat-sidenav-content.tb-site-content {
mat-sidenav-content.tb-site-content:not(.tb-force-fullscreen) {
margin-left: 250px !important;
transition: margin-left $sidenav-anim-timing;
}

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

@ -18,14 +18,14 @@ import {
AfterViewInit,
Component,
computed,
ElementRef,
ElementRef, EventEmitter,
Inject,
OnDestroy,
OnInit,
signal,
ViewChild
} from '@angular/core';
import { skip, startWith, Subject } from 'rxjs';
import { skip, startWith, Subject, Subscription } from 'rxjs';
import { select, Store } from '@ngrx/store';
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 { isDefinedAndNotNull } from '@core/utils';
import { ActionPreferencesPutUserSettings } from '@core/auth/auth.actions';
import { isMainToolbarComponent } from '@home/models/main-toolbar.models';
@Component({
selector: 'tb-home',
@ -85,6 +86,10 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
hideLoadingBar = false;
hideMainToolbar = false;
private toggleSideBarSubscription: Subscription;
private destroy$ = new Subject<void>();
constructor(protected store: Store<AppState>,
@ -125,6 +130,9 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
}
ngOnDestroy() {
if (this.toggleSideBarSubscription) {
this.toggleSideBarSubscription.unsubscribe();
}
this.destroy$.next();
this.destroy$.complete();
}
@ -197,6 +205,22 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
this.searchEnabled = false;
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 {

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 { MenuToggleComponent } from '@modules/home/menu/menu-toggle.component';
import { SideMenuComponent } from '@modules/home/menu/side-menu.component';
import { GithubBadgeComponent } from '@home/components/github-badge/github-badge.component';
import { NotificationBellComponent } from '@home/components/notification/notification-bell.component';
import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component';
import { NotificationBellModule } from '@home/components/notification/notification-bell.module';
import { GithubBadgeModule } from '@home/components/github-badge/github-badge.module';
@NgModule({
declarations:
@ -33,14 +32,13 @@ import { ShowNotificationPopoverComponent } from '@home/components/notification/
HomeComponent,
MenuLinkComponent,
MenuToggleComponent,
SideMenuComponent,
GithubBadgeComponent,
NotificationBellComponent,
ShowNotificationPopoverComponent
SideMenuComponent
],
imports: [
CommonModule,
SharedModule,
NotificationBellModule,
GithubBadgeModule,
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 { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component';
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 { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
@ -254,7 +254,6 @@ const routes: Routes = [
canDeactivate: [ConfirmOnExitGuard],
data: {
breadcrumb: {
labelFunction: dashboardBreadcumbLabelFunction,
icon: 'dashboard'
} as BreadCrumbConfig<DashboardPageComponent>,
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 { DashboardsTableConfigResolver } from './dashboards-table-config.resolver';
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 { Dashboard } from '@app/shared/models/dashboard.models';
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 = [
{
path: 'dashboards',
@ -89,7 +86,6 @@ const routes: Routes = [
canDeactivate: [ConfirmOnExitGuard],
data: {
breadcrumb: {
labelFunction: dashboardBreadcumbLabelFunction,
icon: 'dashboard'
} as BreadCrumbConfig<DashboardPageComponent>,
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 { RuleChainsTableConfigResolver } from '@home/pages/rulechain/rulechains-table-config.resolver';
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 { RuleChainPageComponent } from '@home/pages/rulechain/rulechain-page.component';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
@ -243,7 +243,6 @@ const routes: Routes = [
canDeactivate: [ConfirmOnExitGuard],
data: {
breadcrumb: {
labelFunction: dashboardBreadcumbLabelFunction,
icon: 'dashboard'
} as BreadCrumbConfig<DashboardPageComponent>,
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);
export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction<WidgetEditorComponent> =
((route, translate, component) =>
component?.widget?.widgetName ?
(component.widget.widgetName + (component.widget.deprecated ? ` (${translate.instant('widget.deprecated')})` : '')) : '');
((route, translate, component) => {
const widget = component?.widget ?? route.data?.widgetEditorData?.widget;
return widget?.widgetName ?
(widget.widgetName + (widget.deprecated ? ` (${translate.instant('widget.deprecated')})` : '')) : '';
});
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">
@if (lastBreadcrumb$ | async; as breadcrumb) {
<h1 class="flex-1 gt-sm:!hidden">
@if (componentBreadcrumbsTpl && (lastBreadcrumb$ | async) === null) {
<div class="min-w-0 flex-1">
<ng-container
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}">
*ngTemplateOutlet="componentBreadcrumbsTpl">
</ng-container>
</h1>
}
@for (breadcrumb of breadcrumbs$ | async; track trackByBreadcrumbs; let isLast = $last; let i = $index; let total = $count;) {
<span class="flex flex-row gap-2 lt-md:!hidden">
@switch (isLast) {
@case (false) {
<a [routerLink]="breadcrumb.link" [queryParams]="breadcrumb.queryParams">
<ng-container
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}">
</ng-container>
</a>
}
@case (true) {
<span>
<ng-container
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}">
</ng-container>
</span>
</div>
} @else {
@if (lastBreadcrumb$ | async; as breadcrumb) {
@if (componentBreadcrumbsTpl) {
<div class="flex-1 gt-sm:!hidden">
<ng-container
*ngTemplateOutlet="componentBreadcrumbsTpl">
</ng-container>
</div>
} @else {
<h1 class="flex-1 gt-sm:!hidden">
<ng-container
*ngTemplateOutlet="breadcrumbTpl;context:{breadcrumb: breadcrumb}">
</ng-container>
</h1>
}
}
<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>
</span>
</div>
}
</div>
<ng-template #breadcrumbTpl let-breadcrumb="breadcrumb">

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

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

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

@ -14,18 +14,19 @@
/// limitations under the License.
///
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { BreadCrumb, BreadCrumbConfig } from './breadcrumb';
import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { distinctUntilChanged, filter, first, map, switchMap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { guid } from '@core/utils';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input, OnDestroy,
OnInit,
TemplateRef
} from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { BreadCrumb } from './breadcrumb';
import { BroadcastService } from '@core/services/broadcast.service';
import { ActiveComponentService } from '@core/services/active-component.service';
import { UtilsService } from '@core/services/utils.service';
import { MenuSection, menuSectionMap } from '@core/services/menu.models';
import { MenuService } from '@core/services/menu.service';
import { BreadcrumbService } from '@core/services/breadcrumb.service';
@Component({
selector: 'tb-breadcrumb',
@ -36,116 +37,29 @@ import { MenuService } from '@core/services/menu.service';
})
export class BreadcrumbComponent implements OnInit, OnDestroy {
activeComponentValue: any;
updateBreadcrumbsSubscription: Subscription = null;
@Input()
componentBreadcrumbsTpl?: TemplateRef<any>;
setActiveComponent(activeComponent: any) {
if (this.updateBreadcrumbsSubscription) {
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>>([]);
breadcrumbs$: Observable<BreadCrumb[]> = this.breadcrumbService.breadcrumbs$;
lastBreadcrumb$: Observable<BreadCrumb> = this.breadcrumbService.lastBreadcrumb$;
routerEventsSubscription = this.router.events.pipe(
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) );
private updateBreadcrumbsSubscription: Subscription;
activeComponentSubscription = this.activeComponentService.onActiveComponentChanged().subscribe(comp => this.setActiveComponent(comp));
lastBreadcrumb$ = this.breadcrumbs$.pipe(
map( breadcrumbs => breadcrumbs[breadcrumbs.length - 1])
);
constructor(private router: Router,
private activatedRoute: ActivatedRoute,
private broadcast: BroadcastService,
private activeComponentService: ActiveComponentService,
constructor(private broadcast: BroadcastService,
private breadcrumbService: BreadcrumbService,
private cd: ChangeDetectorRef,
private translate: TranslateService,
private menuService: MenuService,
public utils: UtilsService) {
}
ngOnInit(): void {
this.broadcast.on('updateBreadcrumb', () => {
this.updateBreadcrumbsSubscription = this.broadcast.on('updateBreadcrumb', () => {
this.cd.markForCheck();
});
this.setActiveComponent(this.activeComponentService.getCurrentActiveComponent());
}
ngOnDestroy(): void {
if (this.routerEventsSubscription) {
this.routerEventsSubscription.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);
if (this.updateBreadcrumbsSubscription) {
this.updateBreadcrumbsSubscription.unsubscribe();
}
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";
.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 {
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-background {
background: mat.m2-get-color-from-palette($background, app-bar);
color: mat.m2-get-color-from-palette($foreground, text);
background: var(--mat-toolbar-container-background-color, var(--mat-sys-surface));
color: var(--mat-toolbar-container-text-color, var(--mat-sys-on-surface));
}
&.mat-primary {
.mat-fab-toolbar-background {

Loading…
Cancel
Save