UI: Refactor menu for System administrator

This commit is contained in:
Igor Kulikov 2023-02-09 19:13:44 +02:00
parent 17d1dd258a
commit 61e117f320
43 changed files with 851 additions and 287 deletions

View File

@ -29,6 +29,11 @@
"assets": [
"src/thingsboard.ico",
"src/assets",
{
"glob": "*.svg",
"input": "./node_modules/@mdi/svg/svg/",
"output": "/assets/mdi/"
},
{
"glob": "worker-html.js",
"input": "./node_modules/ace-builds/src-noconflict/",

View File

@ -36,6 +36,7 @@
"@material-ui/core": "4.12.3",
"@material-ui/icons": "4.11.2",
"@material-ui/pickers": "3.3.10",
"@mdi/svg": "^7.1.96",
"@ngrx/effects": "^14.3.3",
"@ngrx/store": "^14.3.3",
"@ngrx/store-devtools": "^14.3.3",

View File

@ -47,8 +47,13 @@ export class AppComponent implements OnInit {
console.log(`ThingsBoard Version: ${env.tbVersion}`);
this.matIconRegistry.addSvgIconSetInNamespace('mdi',
this.domSanitizer.bypassSecurityTrustResourceUrl('./assets/mdi.svg'));
this.matIconRegistry.addSvgIconResolver((name, namespace) => {
if (namespace === 'mdi') {
return this.domSanitizer.bypassSecurityTrustResourceUrl(`./assets/mdi/${name}.svg`);
} else {
return null;
}
});
this.matIconRegistry.addSvgIconLiteral(
'google-logo',

View File

@ -24,7 +24,8 @@ export enum AuthActionTypes {
LOAD_USER = '[Auth] Load User',
UPDATE_USER_DETAILS = '[Auth] Update User Details',
UPDATE_LAST_PUBLIC_DASHBOARD_ID = '[Auth] Update Last Public Dashboard Id',
UPDATE_HAS_REPOSITORY = '[Auth] Change Has Repository'
UPDATE_HAS_REPOSITORY = '[Auth] Change Has Repository',
UPDATE_OPENED_MENU_SECTION = '[Preferences] Update Opened Menu Section'
}
export class ActionAuthAuthenticated implements Action {
@ -61,5 +62,12 @@ export class ActionAuthUpdateHasRepository implements Action {
constructor(readonly payload: { hasRepository: boolean }) {}
}
export class ActionPreferencesUpdateOpenedMenuSection implements Action {
readonly type = AuthActionTypes.UPDATE_OPENED_MENU_SECTION;
constructor(readonly payload: { path: string; opened: boolean }) {}
}
export type AuthActions = ActionAuthAuthenticated | ActionAuthUnauthenticated |
ActionAuthLoadUser | ActionAuthUpdateUserDetails | ActionAuthUpdateLastPublicDashboardId | ActionAuthUpdateHasRepository;
ActionAuthLoadUser | ActionAuthUpdateUserDetails | ActionAuthUpdateLastPublicDashboardId | ActionAuthUpdateHasRepository |
ActionPreferencesUpdateOpenedMenuSection;

View File

@ -0,0 +1,42 @@
///
/// Copyright © 2016-2023 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 { Actions, createEffect, ofType } from '@ngrx/effects';
import { select, Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { UserPreferencesService } from '@core/http/user-preferences.service';
import { mergeMap, withLatestFrom } from 'rxjs/operators';
import { AuthActions, AuthActionTypes } from '@core/auth/auth.actions';
import { selectAuthState } from '@core/auth/auth.selectors';
@Injectable()
export class AuthEffects {
constructor(
private actions$: Actions<AuthActions>,
private store: Store<AppState>,
private userPreferencesService: UserPreferencesService
) {
}
persistUserPreferences = createEffect(() => this.actions$.pipe(
ofType(
AuthActionTypes.UPDATE_OPENED_MENU_SECTION,
),
withLatestFrom(this.store.pipe(select(selectAuthState))),
mergeMap(([action, state]) => this.userPreferencesService.saveUserPreferences(state.authUser, state.userPreferences))
), {dispatch: false});
}

View File

@ -15,6 +15,7 @@
///
import { AuthUser, User } from '@shared/models/user.model';
import { UserPreferences } from '@shared/models/user-preferences.models';
export interface SysParamsState {
userTokenAccessEnabled: boolean;
@ -22,6 +23,7 @@ export interface SysParamsState {
edgesSupportEnabled: boolean;
hasRepository: boolean;
tbelEnabled: boolean;
userPreferences: UserPreferences;
}
export interface AuthPayload extends SysParamsState {

View File

@ -16,6 +16,7 @@
import { AuthPayload, AuthState } from './auth.models';
import { AuthActions, AuthActionTypes } from './auth.actions';
import { initialUserPreferences } from '@shared/models/user-preferences.models';
const emptyUserAuthState: AuthPayload = {
authUser: null,
@ -25,7 +26,8 @@ const emptyUserAuthState: AuthPayload = {
allowedDashboardIds: [],
edgesSupportEnabled: false,
hasRepository: false,
tbelEnabled: false
tbelEnabled: false,
userPreferences: initialUserPreferences
};
export const initialState: AuthState = {
@ -35,10 +37,10 @@ export const initialState: AuthState = {
...emptyUserAuthState
};
export function authReducer(
export const authReducer = (
state: AuthState = initialState,
action: AuthActions
): AuthState {
): AuthState => {
switch (action.type) {
case AuthActionTypes.AUTHENTICATED:
return { ...state, isAuthenticated: true, ...action.payload };
@ -59,7 +61,19 @@ export function authReducer(
case AuthActionTypes.UPDATE_HAS_REPOSITORY:
return { ...state, ...action.payload};
case AuthActionTypes.UPDATE_OPENED_MENU_SECTION:
const openedMenuSections = new Set(state.userPreferences.openedMenuSections);
if (action.payload.opened) {
if (!openedMenuSections.has(action.payload.path)) {
openedMenuSections.add(action.payload.path);
}
} else {
openedMenuSections.delete(action.payload.path);
}
const userPreferences = {...state.userPreferences, ...{ openedMenuSections: Array.from(openedMenuSections)}};
return { ...state, ...{ userPreferences }};
default:
return state;
}
}
};

View File

@ -20,6 +20,7 @@ import { AppState } from '../core.state';
import { AuthState } from './auth.models';
import { take } from 'rxjs/operators';
import { AuthUser } from '@shared/models/user.model';
import { UserPreferences } from '@shared/models/user-preferences.models';
export const selectAuthState = createFeatureSelector< AuthState>(
'auth'
@ -65,18 +66,45 @@ export const selectTbelEnabled = createSelector(
(state: AuthState) => state.tbelEnabled
);
export function getCurrentAuthState(store: Store<AppState>): AuthState {
export const selectUserPreferences = createSelector(
selectAuthState,
(state: AuthState) => state.userPreferences
);
export const selectOpenedMenuSections = createSelector(
selectAuthState,
(state: AuthState) => state.userPreferences.openedMenuSections
);
export const getCurrentAuthState = (store: Store<AppState>): AuthState => {
let state: AuthState;
store.pipe(select(selectAuth), take(1)).subscribe(
val => state = val
);
return state;
}
};
export function getCurrentAuthUser(store: Store<AppState>): AuthUser {
export const getCurrentAuthUser = (store: Store<AppState>): AuthUser => {
let authUser: AuthUser;
store.pipe(select(selectAuthUser), take(1)).subscribe(
val => authUser = val
);
return authUser;
}
};
export const getCurrentUserPreferences = (store: Store<AppState>): UserPreferences => {
let userPreferences: UserPreferences;
store.pipe(select(selectUserPreferences), take(1)).subscribe(
val => userPreferences = val
);
return userPreferences;
};
export const getCurrentOpenedMenuSections = (store: Store<AppState>): string[] => {
let openedMenuSections: string[];
store.pipe(select(selectOpenedMenuSections), take(1)).subscribe(
val => openedMenuSections = val
);
return openedMenuSections;
};

View File

@ -48,6 +48,8 @@ import { OAuth2ClientInfo, PlatformType } from '@shared/models/oauth2.models';
import { isMobileApp } from '@core/utils';
import { TwoFactorAuthProviderType, TwoFaProviderInfo } from '@shared/models/two-factor-auth.models';
import { UserPasswordPolicy } from '@shared/models/settings.models';
import { UserPreferences } from '@shared/models/user-preferences.models';
import { UserPreferencesService } from '@core/http/user-preferences.service';
@Injectable({
providedIn: 'root'
@ -66,6 +68,7 @@ export class AuthService {
private dashboardService: DashboardService,
private adminService: AdminService,
private translate: TranslateService,
private userPreferencesService: UserPreferencesService,
private dialog: MatDialog
) {
}
@ -490,12 +493,17 @@ export class AuthService {
}
}
private loadUserPreferences(authUser: AuthUser): Observable<UserPreferences> {
return this.userPreferencesService.loadUserPreferences(authUser);
}
private loadSystemParams(authPayload: AuthPayload): Observable<SysParamsState> {
const sources = [this.loadIsUserTokenAccessEnabled(authPayload.authUser),
this.fetchAllowedDashboardIds(authPayload),
this.loadIsEdgesSupportEnabled(),
this.loadHasRepository(authPayload.authUser),
this.loadTbelEnabled(authPayload.authUser),
this.loadUserPreferences(authPayload.authUser),
this.timeService.loadMaxDatapointsLimit()];
return forkJoin(sources)
.pipe(map((data) => {
@ -504,10 +512,9 @@ export class AuthService {
const edgesSupportEnabled: boolean = data[2] as boolean;
const hasRepository: boolean = data[3] as boolean;
const tbelEnabled: boolean = data[4] as boolean;
return {userTokenAccessEnabled, allowedDashboardIds, edgesSupportEnabled, hasRepository, tbelEnabled};
}, catchError((err) => {
return of({});
})));
const userPreferences = data[5] as UserPreferences;
return {userTokenAccessEnabled, allowedDashboardIds, edgesSupportEnabled, hasRepository, tbelEnabled, userPreferences};
}, catchError((err) => of({}))));
}
public refreshJwtToken(loadUserElseStoreJwtToken = true): Observable<LoginResponse> {

View File

@ -15,6 +15,7 @@
///
export * from './auth.actions';
export * from './auth.effects';
export * from './auth.models';
export * from './auth.reducer';
export * from './auth.selectors';

View File

@ -32,6 +32,7 @@ import { SettingsEffects } from '@app/core/settings/settings.effects';
import { NotificationState } from '@app/core/notification/notification.models';
import { notificationReducer } from '@app/core/notification/notification.reducer';
import { NotificationEffects } from '@app/core/notification/notification.effects';
import { AuthEffects } from '@core/auth/auth.effects';
export const reducers: ActionReducerMap<AppState> = {
load: loadReducer,
@ -49,6 +50,7 @@ if (!env.production) {
}
export const effects: Type<any>[] = [
AuthEffects,
SettingsEffects,
NotificationEffects
];

View File

@ -39,4 +39,5 @@ export * from './tenant.service';
export * from './tenant-profile.service';
export * from './ui-settings.service';
export * from './user.service';
export * from './user-preferences.service';
export * from './widget.service';

View File

@ -0,0 +1,58 @@
///
/// Copyright © 2016-2023 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 { AttributeService } from '@core/http/attribute.service';
import { Observable } from 'rxjs';
import { initialUserPreferences, UserPreferences } from '@shared/models/user-preferences.models';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { EntityType } from '@shared/models/entity-type.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { map } from 'rxjs/operators';
import { AuthUser } from '@shared/models/user.model';
const USER_PREFERENCES_ATTRIBUTE_KEY = 'user_preferences';
@Injectable({
providedIn: 'root'
})
export class UserPreferencesService {
constructor(
private store: Store<AppState>,
private attributeService: AttributeService
) {}
public loadUserPreferences(authUser: AuthUser): Observable<UserPreferences> {
return this.attributeService.getEntityAttributes({id: authUser.userId, entityType: EntityType.USER},
AttributeScope.SERVER_SCOPE, [USER_PREFERENCES_ATTRIBUTE_KEY], { ignoreLoading: true, ignoreErrors: true }).pipe(
map((attributes) => {
const found = ((attributes || []).find(data => data.key === USER_PREFERENCES_ATTRIBUTE_KEY));
return found ? JSON.parse(found.value) : initialUserPreferences;
})
);
}
public saveUserPreferences(authUser: AuthUser, userPreferences: UserPreferences): Observable<void> {
return this.attributeService.saveEntityAttributes({id: authUser.userId, entityType: EntityType.USER},
AttributeScope.SERVER_SCOPE,
[{key: USER_PREFERENCES_ATTRIBUTE_KEY, value: JSON.stringify(userPreferences)}],
{ ignoreLoading: true, ignoreErrors: true });
}
}

View File

@ -0,0 +1,41 @@
///
/// Copyright © 2016-2023 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 { Observable, Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class ActiveComponentService {
private activeComponent: any;
private activeComponentChangedSubject: Subject<any> = new Subject<any>();
public getCurrentActiveComponent(): any {
return this.activeComponent;
}
public setCurrentActiveComponent(component: any): void {
this.activeComponent = component;
this.activeComponentChangedSubject.next(component);
}
public onActiveComponentChanged(): Observable<any> {
return this.activeComponentChangedSubject.asObservable();
}
}

View File

@ -26,6 +26,7 @@ export interface MenuSection extends HasUUID{
isMdiIcon?: boolean;
height?: string;
pages?: Array<MenuSection>;
opened?: boolean;
}
export interface HomeSection {

View File

@ -18,13 +18,14 @@ import { Injectable } from '@angular/core';
import { AuthService } from '../auth/auth.service';
import { select, Store } from '@ngrx/store';
import { AppState } from '../core.state';
import { selectAuth, selectIsAuthenticated } from '../auth/auth.selectors';
import { getCurrentOpenedMenuSections, selectAuth, selectIsAuthenticated } from '../auth/auth.selectors';
import { take } from 'rxjs/operators';
import { HomeSection, MenuSection } from '@core/services/menu.models';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { Authority } from '@shared/models/authority.enum';
import { guid } from '@core/utils';
import { AuthState } from '@core/auth/auth.models';
import { Router } from '@angular/router';
@Injectable({
providedIn: 'root'
@ -34,7 +35,9 @@ export class MenuService {
menuSections$: Subject<Array<MenuSection>> = new BehaviorSubject<Array<MenuSection>>([]);
homeSections$: Subject<Array<HomeSection>> = new BehaviorSubject<Array<HomeSection>>([]);
constructor(private store: Store<AppState>, private authService: AuthService) {
constructor(private store: Store<AppState>,
private authService: AuthService,
private router: Router) {
this.store.pipe(select(selectIsAuthenticated)).subscribe(
(authenticated: boolean) => {
if (authenticated) {
@ -64,6 +67,12 @@ export class MenuService {
homeSections = this.buildCustomerUserHome(authState);
break;
}
const url = this.router.url;
const openedMenuSections = getCurrentOpenedMenuSections(this.store);
menuSections.filter(section => section.type === 'toggle' &&
(url.startsWith(section.path) || openedMenuSections.includes(section.path))).forEach(
section => section.opened = true
);
this.menuSections$.next(menuSections);
this.homeSections$.next(homeSections);
}
@ -98,17 +107,34 @@ export class MenuService {
},
{
id: guid(),
name: 'widget.widget-library',
type: 'link',
path: '/widgets-bundles',
icon: 'now_widgets'
name: 'admin.resources',
type: 'toggle',
path: '/resources',
height: '80px',
icon: 'folder',
pages: [
{
id: guid(),
name: 'widget.widget-library',
type: 'link',
path: '/resources/widgets-bundles',
icon: 'now_widgets'
},
{
id: guid(),
name: 'resource.resources-library',
type: 'link',
path: '/resources/resources-library',
icon: 'mdi:rhombus-split',
isMdiIcon: true
}
]
},
{
id: guid(),
name: 'admin.system-settings',
type: 'toggle',
type: 'link',
path: '/settings',
height: '320px',
icon: 'settings',
pages: [
{
@ -127,40 +153,12 @@ export class MenuService {
},
{
id: guid(),
name: 'admin.sms-provider',
name: 'admin.notifications',
type: 'link',
path: '/settings/sms-provider',
icon: 'sms'
},
{
id: guid(),
name: 'admin.security-settings',
type: 'link',
path: '/settings/security-settings',
icon: 'security'
},
{
id: guid(),
name: 'admin.oauth2.oauth2',
type: 'link',
path: '/settings/oauth2',
icon: 'security'
},
{
id: guid(),
name: 'admin.2fa.2fa',
type: 'link',
path: '/settings/2fa',
icon: 'mdi:two-factor-authentication',
path: '/settings/notifications',
icon: 'mdi:message-badge',
isMdiIcon: true
},
{
id: guid(),
name: 'resource.resources-library',
type: 'link',
path: '/settings/resources-library',
icon: 'folder'
},
{
id: guid(),
name: 'admin.queues',
@ -169,6 +167,38 @@ export class MenuService {
icon: 'swap_calls'
},
]
},
{
id: guid(),
name: 'security.security',
type: 'link',
path: '/security-settings',
icon: 'security',
pages: [
{
id: guid(),
name: 'admin.general',
type: 'link',
path: '/security-settings/general',
icon: 'settings_applications'
},
{
id: guid(),
name: 'admin.oauth2.oauth2',
type: 'link',
path: '/security-settings/oauth2',
icon: 'mdi:shield-account',
isMdiIcon: true
},
{
id: guid(),
name: 'admin.2fa.2fa',
type: 'link',
path: '/security-settings/2fa',
icon: 'mdi:two-factor-authentication',
isMdiIcon: true
}
]
}
);
return sections;

View File

@ -21,9 +21,9 @@
<div class="mat-toolbar-tools">
<div fxLayout="row" fxLayoutAlign="start center" fxLayout.xs="column" fxLayoutAlign.xs="center start" class="title-container">
<span class="tb-entity-table-title">{{telemetryTypeTranslationsMap.get(attributeScope) | translate}}</span>
<mat-form-field class="mat-block tb-attribute-scope" *ngIf="!disableAttributeScopeSelection">
<mat-form-field class="mat-block tb-attribute-scope" *ngIf="!disableAttributeScopeSelection && !attributeScopeSelectionReadonly">
<mat-label translate>attribute.attributes-scope</mat-label>
<mat-select [disabled]="(isLoading$ | async) || attributeScopeSelectionReadonly"
<mat-select [disabled]="(isLoading$ | async)"
[ngModel]="attributeScope"
(ngModelChange)="attributeScopeChanged($event)">
<mat-option *ngFor="let scope of attributeScopes" [value]="scope">

View File

@ -17,7 +17,13 @@
-->
<mat-card class="settings-card">
<mat-toolbar class="details-toolbar">
<div class="mat-toolbar-tools" fxLayout="row" fxLayoutAlign="start center">
<div class="mat-toolbar-tools" fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<button mat-icon-button
matTooltip="{{ 'action.back' | translate }}"
matTooltipPosition="above"
(click)="goBack()">
<mat-icon>arrow_back</mat-icon>
</button>
<div class="tb-details-title-header" fxLayout="column" fxLayoutAlign="center start">
<div class="tb-details-title tb-ellipsis">{{ headerTitle }}</div>
<div class="tb-details-subtitle tb-ellipsis">{{ headerSubtitle }}</div>

View File

@ -144,6 +144,10 @@ export class EntityDetailsPageComponent extends EntityDetailsPanelComponent impl
return this.detailsForm;
}
goBack(): void {
this.router.navigate(['../'], { relativeTo: this.route });
}
private onUpdateEntity() {
this.broadcast.broadcast('updateBreadcrumb');
this.isReadOnly = this.entitiesTableConfig.detailsReadonly(this.entity);
@ -164,7 +168,7 @@ export class EntityDetailsPageComponent extends EntityDetailsPanelComponent impl
if (result) {
this.entitiesTableConfig.deleteEntity(entity.id).subscribe(
() => {
this.router.navigate(['../'], {relativeTo: this.route});
this.goBack();
}
);
}

View File

@ -175,10 +175,12 @@ import { AssetProfileDialogComponent } from '@home/components/profile/asset-prof
import { AssetProfileAutocompleteComponent } from '@home/components/profile/asset-profile-autocomplete.component';
import { MODULES_MAP } from '@shared/models/constants';
import { modulesMap } from '@modules/common/modules-map';
import { RouterTabsComponent } from '@home/components/router-tabs.component';
@NgModule({
declarations:
[
RouterTabsComponent,
EntitiesTableComponent,
AddEntityDialogComponent,
DetailsPanelComponent,
@ -332,6 +334,7 @@ import { modulesMap } from '@modules/common/modules-map';
DeviceProfileCommonModule
],
exports: [
RouterTabsComponent,
EntitiesTableComponent,
AddEntityDialogComponent,
DetailsPanelComponent,

View File

@ -161,9 +161,9 @@ export class QueueFormComponent implements ControlValueAccessor, OnInit, OnDestr
this.queueFormGroup.get('additionalInfo').get('description')
.patchValue(this.modelValue.additionalInfo?.description, {emitEvent: false});
this.submitStrategyTypeChanged();
}
if (!this.disabled && !this.queueFormGroup.valid) {
this.updateModel();
if (!this.disabled && !this.queueFormGroup.valid) {
this.updateModel();
}
}
}

View File

@ -0,0 +1,56 @@
/**
* Copyright © 2016-2023 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.
*/
:host {
display: flex;
width: 100%;
height: 100%;
.mat-tab-nav-bar.tb-router-tabs {
a.mat-tab-link {
display: inline-flex;
align-items: center;
overflow: hidden;
line-height: 40px;
opacity: 1;
&:hover {
border-bottom: none;
background-color: rgba(255,255,255,0.08);
}
&:focus {
border-bottom: none;
}
&.tb-active {
font-weight: 500;
background-color: rgba(255, 255, 255, .15);
}
mat-icon {
margin-right: 8px;
margin-left: 0;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.tb-router-tabs-content {
overflow: auto;
position: relative;
height: 100%;
}
}

View File

@ -0,0 +1,35 @@
<!--
Copyright © 2016-2023 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.
-->
<div fxFlex fxLayout="column">
<nav mat-tab-nav-bar backgroundColor="primary" class="tb-router-tabs">
<a *ngFor="let tab of tabs$ | async"
routerLink="{{tab.path}}"
routerLinkActive
#rla="routerLinkActive"
mat-tab-link
[ngClass]="{'tb-active': rla.isActive}"
[active]="rla.isActive">
<mat-icon *ngIf="!tab.isMdiIcon && tab.icon !== null" class="tb-mat-18">{{tab.icon}}</mat-icon>
<mat-icon *ngIf="tab.isMdiIcon && tab.icon !== null" [svgIcon]="tab.icon" class="tb-mat-18"></mat-icon>
<span>{{tab.name | translate}}</span>
</a>
</nav>
<div fxFlex fxLayout="column" class="tb-router-tabs-content">
<router-outlet (activate)="activeComponentChanged($event)"></router-outlet>
</div>
</div>

View File

@ -0,0 +1,65 @@
///
/// Copyright © 2016-2023 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 { AfterViewInit, Component, Inject, OnInit } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { WINDOW } from '@core/services/window.service';
import { BreakpointObserver } from '@angular/cdk/layout';
import { ActivatedRoute, Router } from '@angular/router';
import { MenuService } from '@core/services/menu.service';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { MenuSection } from '@core/services/menu.models';
import { instanceOfSearchableComponent } from '@home/models/searchable-component.models';
import { BroadcastService } from '@core/services/broadcast.service';
import { ActiveComponentService } from '@core/services/active-component.service';
@Component({
selector: 'tb-router-tabs',
templateUrl: './router-tabs.component.html',
styleUrls: ['./route-tabs.component.scss']
})
export class RouterTabsComponent extends PageComponent implements AfterViewInit, OnInit {
tabs$: Observable<Array<MenuSection>> = this.menuService.menuSections().pipe(
map(sections => {
const found = sections.find(section => section.path === `/${this.activatedRoute.routeConfig.path}`);
return found ? found.pages : [];
})
);
constructor(protected store: Store<AppState>,
private activatedRoute: ActivatedRoute,
public router: Router,
private menuService: MenuService,
private activeComponentService: ActiveComponentService,
@Inject(WINDOW) private window: Window,
public breakpointObserver: BreakpointObserver) {
super(store);
}
ngOnInit() {
}
ngAfterViewInit() {}
activeComponentChanged(activeComponent: any) {
this.activeComponentService.setCurrentActiveComponent(activeComponent);
}
}

View File

@ -35,7 +35,7 @@
</mat-sidenav>
<mat-sidenav-content>
<div fxLayout="column" role="main" style="height: 100%;">
<mat-toolbar fxLayout="row" color="primary" class="mat-elevation-z1 tb-primary-toolbar">
<mat-toolbar fxLayout="row" color="primary" class="tb-primary-toolbar">
<button [fxShow]="!forceFullscreen" mat-icon-button id="main"
[ngClass]="{'tb-invisible': displaySearchMode()}"
fxHide.gt-sm (click)="sidenav.toggle()">
@ -52,7 +52,7 @@
<mat-icon class="material-icons">arrow_back</mat-icon>
</button>
<tb-breadcrumb [fxShow]="!displaySearchMode()"
fxFlex [activeComponent]="activeComponent" class="mat-toolbar-tools">
fxFlex class="mat-toolbar-tools">
</tb-breadcrumb>
<div [fxShow]="displaySearchMode()" fxFlex fxLayout="row" class="tb-dark">
<mat-form-field fxFlex floatLabel="always">
@ -73,7 +73,7 @@
<tb-user-menu [displayUserInfo]="!displaySearchMode()"></tb-user-menu>
</mat-toolbar>
<mat-progress-bar color="warn" style="z-index: 10; margin-bottom: -4px; width: 100%;" mode="indeterminate"
*ngIf="isLoading$ | async">
*ngIf="!hideLoadingBar && (isLoading$ | async)">
</mat-progress-bar>
<div fxFlex fxLayout="column" tb-toast class="tb-main-content">
<router-outlet (activate)="activeComponentChanged($event)"></router-outlet>

View File

@ -30,6 +30,8 @@ import { MatSidenav } from '@angular/material/sidenav';
import { AuthState } from '@core/auth/auth.models';
import { WINDOW } from '@core/services/window.service';
import { instanceOfSearchableComponent, ISearchableComponent } from '@home/models/searchable-component.models';
import { ActiveComponentService } from '@core/services/active-component.service';
import { RouterTabsComponent } from '@home/components/router-tabs.component';
@Component({
selector: 'tb-home',
@ -65,8 +67,11 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
showSearch = false;
searchText = '';
hideLoadingBar = false;
constructor(protected store: Store<AppState>,
@Inject(WINDOW) private window: Window,
private activeComponentService: ActiveComponentService,
public breakpointObserver: BreakpointObserver) {
super(store);
}
@ -133,6 +138,8 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
this.showSearch = false;
this.searchText = '';
this.activeComponent = activeComponent;
this.hideLoadingBar = activeComponent && activeComponent instanceof RouterTabsComponent;
this.activeComponentService.setCurrentActiveComponent(activeComponent);
if (this.activeComponent && instanceOfSearchableComponent(this.activeComponent)) {
this.searchEnabled = true;
this.searchableComponent = this.activeComponent;

View File

@ -15,8 +15,8 @@
limitations under the License.
-->
<a mat-button routerLinkActive="tb-active" [routerLinkActiveOptions]="{paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored'}" routerLink="{{section.path}}">
<mat-icon *ngIf="!section.isMdiIcon && section.icon != null" class="material-icons">{{section.icon}}</mat-icon>
<mat-icon *ngIf="section.isMdiIcon && section.icon != null" [svgIcon]="section.icon"></mat-icon>
<a mat-button routerLinkActive="tb-active" [routerLinkActiveOptions]="{paths: 'subset', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored'}" routerLink="{{section.path}}">
<mat-icon *ngIf="!section.isMdiIcon && section.icon !== null" class="tb-mat-18">{{section.icon}}</mat-icon>
<mat-icon *ngIf="section.isMdiIcon && section.icon !== null" [svgIcon]="section.icon" class="tb-mat-18"></mat-icon>
<span>{{section.name | translate}}</span>
</a>

View File

@ -15,13 +15,12 @@
limitations under the License.
-->
<a mat-button class="tb-button-toggle"
routerLinkActive="tb-active" [routerLinkActiveOptions]="{paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored'}" routerLink="{{section.path}}">
<mat-icon *ngIf="!section.isMdiIcon && section.icon != null" class="material-icons">{{section.icon}}</mat-icon>
<mat-icon *ngIf="section.isMdiIcon && section.icon != null" [svgIcon]="section.icon"></mat-icon>
<a mat-button class="tb-button-toggle" (click)="toggleSection()">
<mat-icon *ngIf="!section.isMdiIcon && section.icon !== null" class="tb-mat-18">{{section.icon}}</mat-icon>
<mat-icon *ngIf="section.isMdiIcon && section.icon !== null" [svgIcon]="section.icon" class="tb-mat-18"></mat-icon>
<span>{{section.name | translate}}</span>
<span class=" pull-right fa fa-chevron-down tb-toggle-icon"
[ngClass]="{'tb-toggled' : sectionActive()}"></span>
[ngClass]="{'tb-toggled' : section.opened}"></span>
</a>
<ul id="docs-menu-{{section.name | nospace}}" class="tb-menu-toggle-list" [ngStyle]="{height: sectionHeight()}">
<li *ngFor="let page of section.pages; trackBy: trackBySectionPages">

View File

@ -17,6 +17,9 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { MenuSection } from '@core/services/menu.models';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { ActionPreferencesUpdateOpenedMenuSection } from '@core/auth/auth.actions';
@Component({
selector: 'tb-menu-toggle',
@ -28,24 +31,26 @@ export class MenuToggleComponent implements OnInit {
@Input() section: MenuSection;
constructor(private router: Router) {
constructor(private router: Router,
private store: Store<AppState>) {
}
ngOnInit() {
}
sectionActive(): boolean {
return this.router.isActive(this.section.path, false);
}
sectionHeight(): string {
if (this.router.isActive(this.section.path, false)) {
if (this.section.opened) {
return this.section.height;
} else {
return '0px';
}
}
toggleSection() {
this.section.opened = !this.section.opened;
this.store.dispatch(new ActionPreferencesUpdateOpenedMenuSection({path: this.section.path, opened: this.section.opened}));
}
trackBySectionPages(index: number, section: MenuSection){
return section.id;
}

View File

@ -36,6 +36,16 @@ import { QueuesTableConfigResolver } from '@home/pages/admin/queue/queues-table-
import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-admin-settings.component';
import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component';
import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component';
import { WidgetsBundlesTableConfigResolver } from '@home/pages/widget/widgets-bundles-table-config.resolver';
import {
WidgetEditorAddDataResolver, widgetEditorBreadcumbLabelFunction,
WidgetEditorDataResolver,
WidgetsBundleResolver,
WidgetsTypesDataResolver, widgetTypesBreadcumbLabelFunction
} from '@home/pages/widget/widget-library-routing.module';
import { WidgetLibraryComponent } from '@home/pages/widget/widget-library.component';
import { WidgetEditorComponent } from '@home/pages/widget/widget-editor.component';
import { RouterTabsComponent } from '@home/components/router-tabs.component';
@Injectable()
export class OAuth2LoginProcessingUrlResolver implements Resolve<string> {
@ -49,8 +59,146 @@ export class OAuth2LoginProcessingUrlResolver implements Resolve<string> {
}
const routes: Routes = [
{
path: 'resources',
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
breadcrumb: {
label: 'admin.resources',
icon: 'folder'
}
},
children: [
{
path: '',
children: [],
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
redirectTo: '/resources/widgets-bundles'
}
},
{
path: 'widgets-bundles',
data: {
breadcrumb: {
label: 'widgets-bundle.widgets-bundles',
icon: 'now_widgets'
}
},
children: [
{
path: '',
component: EntitiesTableComponent,
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widgets-bundle.widgets-bundles'
},
resolve: {
entitiesTableConfig: WidgetsBundlesTableConfigResolver
}
},
{
path: ':widgetsBundleId/widgetTypes',
data: {
breadcrumb: {
labelFunction: widgetTypesBreadcumbLabelFunction,
icon: 'now_widgets'
} as BreadCrumbConfig<any>
},
resolve: {
widgetsBundle: WidgetsBundleResolver
},
children: [
{
path: '',
component: WidgetLibraryComponent,
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widget.widget-library'
},
resolve: {
widgetsData: WidgetsTypesDataResolver
}
},
{
path: ':widgetTypeId',
component: WidgetEditorComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widget.editor',
breadcrumb: {
labelFunction: widgetEditorBreadcumbLabelFunction,
icon: 'insert_chart'
} as BreadCrumbConfig<WidgetEditorComponent>
},
resolve: {
widgetEditorData: WidgetEditorDataResolver
}
},
{
path: 'add/:widgetType',
component: WidgetEditorComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widget.editor',
breadcrumb: {
labelFunction: widgetEditorBreadcumbLabelFunction,
icon: 'insert_chart'
} as BreadCrumbConfig<WidgetEditorComponent>
},
resolve: {
widgetEditorData: WidgetEditorAddDataResolver
}
}
]
}
]
},
{
path: 'resources-library',
data: {
breadcrumb: {
label: 'resource.resources-library',
icon: 'mdi:rhombus-split'
}
},
children: [
{
path: '',
component: EntitiesTableComponent,
data: {
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'resource.resources-library',
},
resolve: {
entitiesTableConfig: ResourcesLibraryTableConfigResolver
}
},
{
path: ':entityId',
component: EntityDetailsPageComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
breadcrumb: {
labelFunction: entityDetailsPageBreadcrumbLabelFunction,
icon: 'mdi:rhombus-split'
} as BreadCrumbConfig<EntityDetailsPageComponent>,
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'resource.resources-library'
},
resolve: {
entitiesTableConfig: ResourcesLibraryTableConfigResolver
}
}
]
}
]
},
{
path: 'settings',
component: RouterTabsComponent,
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
breadcrumb: {
@ -97,98 +245,18 @@ const routes: Routes = [
}
},
{
path: 'sms-provider',
path: 'notifications',
component: SmsProviderComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.sms-provider-settings',
title: 'admin.notifications-settings',
breadcrumb: {
label: 'admin.sms-provider',
icon: 'sms'
label: 'admin.notifications',
icon: 'mdi:message-badge'
}
}
},
{
path: 'security-settings',
component: SecuritySettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.security-settings',
breadcrumb: {
label: 'admin.security-settings',
icon: 'security'
}
}
},
{
path: 'oauth2',
component: OAuth2SettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.oauth2.oauth2',
breadcrumb: {
label: 'admin.oauth2.oauth2',
icon: 'security'
}
},
resolve: {
loginProcessingUrl: OAuth2LoginProcessingUrlResolver
}
},
{
path: 'home',
component: HomeSettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.TENANT_ADMIN],
title: 'admin.home-settings',
breadcrumb: {
label: 'admin.home-settings',
icon: 'settings_applications'
}
}
},
{
path: 'resources-library',
data: {
breadcrumb: {
label: 'resource.resources-library',
icon: 'folder'
}
},
children: [
{
path: '',
component: EntitiesTableComponent,
data: {
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'resource.resources-library',
},
resolve: {
entitiesTableConfig: ResourcesLibraryTableConfigResolver
}
},
{
path: ':entityId',
component: EntityDetailsPageComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
breadcrumb: {
labelFunction: entityDetailsPageBreadcrumbLabelFunction,
icon: 'folder'
} as BreadCrumbConfig<EntityDetailsPageComponent>,
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'resource.resources-library'
},
resolve: {
entitiesTableConfig: ResourcesLibraryTableConfigResolver
}
}
]
},
{
path: 'queues',
data: {
@ -228,16 +296,15 @@ const routes: Routes = [
]
},
{
path: '2fa',
component: TwoFactorAuthSettingsComponent,
path: 'home',
component: HomeSettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.2fa.2fa',
auth: [Authority.TENANT_ADMIN],
title: 'admin.home-settings',
breadcrumb: {
label: 'admin.2fa.2fa',
icon: 'mdi:two-factor-authentication',
isMdiIcon: true
label: 'admin.home-settings',
icon: 'settings_applications'
}
}
},
@ -266,6 +333,98 @@ const routes: Routes = [
icon: 'settings_backup_restore'
}
}
},
{
path: 'security-settings',
redirectTo: '/security-settings/general'
},
{
path: 'oauth2',
redirectTo: '/security-settings/oauth2'
},
{
path: 'resources-library',
pathMatch: 'full',
redirectTo: '/resources/resources-library'
},
{
path: 'resources-library/:entityId',
redirectTo: '/resources/resources-library/:entityId'
},
{
path: '2fa',
redirectTo: '/security-settings/2fa'
},
{
path: 'sms-provider',
redirectTo: '/settings/notifications'
}
]
},
{
path: 'security-settings',
component: RouterTabsComponent,
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
breadcrumb: {
label: 'security.security',
icon: 'security'
}
},
children: [
{
path: '',
children: [],
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
redirectTo: {
SYS_ADMIN: '/security-settings/general',
TENANT_ADMIN: '/security-settings/audit-logs'
}
}
},
{
path: 'general',
component: SecuritySettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.general',
breadcrumb: {
label: 'admin.general',
icon: 'settings_applications'
}
}
},
{
path: '2fa',
component: TwoFactorAuthSettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.2fa.2fa',
breadcrumb: {
label: 'admin.2fa.2fa',
icon: 'mdi:two-factor-authentication',
isMdiIcon: true
}
}
},
{
path: 'oauth2',
component: OAuth2SettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN],
title: 'admin.oauth2.oauth2',
breadcrumb: {
label: 'admin.oauth2.oauth2',
icon: 'mdi:shield-account'
}
},
resolve: {
loginProcessingUrl: OAuth2LoginProcessingUrlResolver
}
}
]
}
@ -277,7 +436,12 @@ const routes: Routes = [
providers: [
OAuth2LoginProcessingUrlResolver,
ResourcesLibraryTableConfigResolver,
QueuesTableConfigResolver
QueuesTableConfigResolver,
WidgetsBundlesTableConfigResolver,
WidgetsBundleResolver,
WidgetsTypesDataResolver,
WidgetEditorDataResolver,
WidgetEditorAddDataResolver
]
})
export class AdminRoutingModule { }

View File

@ -123,7 +123,7 @@ export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableC
if ($event) {
$event.stopPropagation();
}
const url = this.router.createUrlTree(['settings', 'resources-library', resourceInfo.id.id]);
const url = this.router.createUrlTree(['resources', 'resources-library', resourceInfo.id.id]);
this.router.navigateByUrl(url);
}

View File

@ -19,7 +19,7 @@
<mat-card class="settings-card">
<mat-card-title>
<div fxLayout="row">
<span class="mat-headline" translate>admin.sms-provider-settings</span>
<span class="mat-headline" translate>admin.notifications-settings</span>
<span fxFlex></span>
<div tb-help="smsProviderSettings"></div>
</div>

View File

@ -19,6 +19,6 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai
export const entityDetailsPageBreadcrumbLabelFunction: BreadCrumbLabelFunction<EntityDetailsPageComponent>
= ((route, translate, component) => {
return component.entity?.name || component.headerSubtitle;
return component.entity?.name;
});

View File

@ -36,8 +36,3 @@
label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab">
<tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table>
</mat-tab>
<mat-tab *ngIf="entity"
label="{{ 'tenant.events' | translate }}" #eventsTab="matTab">
<tb-event-table [defaultEventType]="eventTypes.ERROR" [active]="eventsTab.isActive" [tenantId]="nullUid"
[entityId]="entity.id"></tb-event-table>
</mat-tab>

View File

@ -123,82 +123,22 @@ export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction<WidgetE
export const routes: Routes = [
{
path: 'widgets-bundles',
data: {
breadcrumb: {
label: 'widgets-bundle.widgets-bundles',
icon: 'now_widgets'
}
},
children: [
{
path: '',
component: EntitiesTableComponent,
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widgets-bundle.widgets-bundles'
},
resolve: {
entitiesTableConfig: WidgetsBundlesTableConfigResolver
}
},
{
path: ':widgetsBundleId/widgetTypes',
data: {
breadcrumb: {
labelFunction: widgetTypesBreadcumbLabelFunction,
icon: 'now_widgets'
} as BreadCrumbConfig<any>
},
resolve: {
widgetsBundle: WidgetsBundleResolver
},
children: [
{
path: '',
component: WidgetLibraryComponent,
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widget.widget-library'
},
resolve: {
widgetsData: WidgetsTypesDataResolver
}
},
{
path: ':widgetTypeId',
component: WidgetEditorComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widget.editor',
breadcrumb: {
labelFunction: widgetEditorBreadcumbLabelFunction,
icon: 'insert_chart'
} as BreadCrumbConfig<WidgetEditorComponent>
},
resolve: {
widgetEditorData: WidgetEditorDataResolver
}
},
{
path: 'add/:widgetType',
component: WidgetEditorComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
title: 'widget.editor',
breadcrumb: {
labelFunction: widgetEditorBreadcumbLabelFunction,
icon: 'insert_chart'
} as BreadCrumbConfig<WidgetEditorComponent>
},
resolve: {
widgetEditorData: WidgetEditorAddDataResolver
}
}
]
}
]
pathMatch: 'full',
redirectTo: '/resources/widgets-bundles'
},
{
path: 'widgets-bundles/:widgetsBundleId/widgetTypes',
pathMatch: 'full',
redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes',
},
{
path: 'widgets-bundles/:widgetsBundleId/widgetTypes/:widgetTypeId',
pathMatch: 'full',
redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/:widgetTypeId',
},
{
path: 'widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType',
redirectTo: '/resources/widgets-bundles/:widgetsBundleId/widgetTypes/add/:widgetType',
}
];
@ -206,12 +146,6 @@ export const routes: Routes = [
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: [
WidgetsBundlesTableConfigResolver,
WidgetsBundleResolver,
WidgetsTypesDataResolver,
WidgetEditorDataResolver,
WidgetEditorAddDataResolver
]
providers: []
})
export class WidgetLibraryRoutingModule { }

View File

@ -160,7 +160,7 @@ export class WidgetsBundlesTableConfigResolver implements Resolve<EntityTableCon
if ($event) {
$event.stopPropagation();
}
this.router.navigateByUrl(`widgets-bundles/${widgetsBundle.id.id}/widgetTypes`);
this.router.navigateByUrl(`resources/widgets-bundles/${widgetsBundle.id.id}/widgetTypes`);
}
exportWidgetsBundle($event: Event, widgetsBundle: WidgetsBundle) {

View File

@ -17,25 +17,32 @@
-->
<div fxFlex class="tb-breadcrumb" fxLayout="row">
<h1 fxFlex fxHide.gt-sm *ngIf="lastBreadcrumb$ | async; let breadcrumb">
{{ breadcrumb.ignoreTranslate ? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : breadcrumb.label) : (breadcrumb.label | translate) }}
{{ breadcrumb.ignoreTranslate
? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : utils.customTranslation(breadcrumb.label, breadcrumb.label))
: (breadcrumb.label | translate) }}
</h1>
<span fxHide.lt-md fxLayout="row" *ngFor="let breadcrumb of breadcrumbs$ | async; trackBy: trackByBreadcrumbs; last as isLast;" [ngSwitch]="isLast">
<a *ngSwitchCase="false" [routerLink]="breadcrumb.link" [queryParams]="breadcrumb.queryParams">
<mat-icon *ngIf="breadcrumb.isMdiIcon" [svgIcon]="breadcrumb.icon">
</mat-icon>
<mat-icon *ngIf="!breadcrumb.isMdiIcon" class="material-icons">
{{ breadcrumb.icon }}
</mat-icon>
{{ breadcrumb.ignoreTranslate ? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : breadcrumb.label) : (breadcrumb.label | translate) }}
<ng-container
*ngTemplateOutlet="breadcrumbWithIcon;context:{breadcrumb: breadcrumb}">
</ng-container>
</a>
<span *ngSwitchCase="true">
<mat-icon *ngIf="breadcrumb.isMdiIcon" [svgIcon]="breadcrumb.icon">
</mat-icon>
<mat-icon *ngIf="!breadcrumb.isMdiIcon" class="material-icons">
{{ breadcrumb.icon }}
</mat-icon>
{{ breadcrumb.ignoreTranslate ? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : breadcrumb.label) : (breadcrumb.label | translate) }}
<ng-container
*ngTemplateOutlet="breadcrumbWithIcon;context:{breadcrumb: breadcrumb}">
</ng-container>
</span>
<span class="divider" [fxHide]="isLast"> > </span>
</span>
</div>
<ng-template #breadcrumbWithIcon let-breadcrumb="breadcrumb">
<img *ngIf="breadcrumb.iconUrl" [src]="breadcrumb.iconUrl"/>
<mat-icon *ngIf="!breadcrumb.iconUrl && breadcrumb.isMdiIcon" [svgIcon]="breadcrumb.icon">
</mat-icon>
<mat-icon *ngIf="!breadcrumb.iconUrl && !breadcrumb.isMdiIcon">
{{ breadcrumb.icon }}
</mat-icon>
{{ breadcrumb.ignoreTranslate
? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : utils.customTranslation(breadcrumb.label, breadcrumb.label))
: (breadcrumb.label | translate) }}
</ng-template>

View File

@ -18,10 +18,12 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { BreadCrumb, BreadCrumbConfig } from './breadcrumb';
import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { guid } from '@core/utils';
import { BroadcastService } from '@core/services/broadcast.service';
import { ActiveComponentService } from '@core/services/active-component.service';
import { UtilsService } from '@core/services/utils.service';
@Component({
selector: 'tb-breadcrumb',
@ -34,8 +36,7 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
activeComponentValue: any;
updateBreadcrumbsSubscription: Subscription = null;
@Input()
set activeComponent(activeComponent: any) {
setActiveComponent(activeComponent: any) {
if (this.updateBreadcrumbsSubscription) {
this.updateBreadcrumbsSubscription.unsubscribe();
this.updateBreadcrumbsSubscription = null;
@ -48,7 +49,7 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
}
}
breadcrumbs$: Subject<Array<BreadCrumb>> = new BehaviorSubject<Array<BreadCrumb>>(this.buildBreadCrumbs(this.activatedRoute.snapshot));
breadcrumbs$: Subject<Array<BreadCrumb>> = new BehaviorSubject<Array<BreadCrumb>>([]);
routerEventsSubscription = this.router.events.pipe(
filter((event) => event instanceof NavigationEnd ),
@ -56,6 +57,8 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
map( () => this.buildBreadCrumbs(this.activatedRoute.snapshot) )
).subscribe(breadcrumns => this.breadcrumbs$.next(breadcrumns) );
activeComponentSubscription = this.activeComponentService.onActiveComponentChanged().subscribe(comp => this.setActiveComponent(comp));
lastBreadcrumb$ = this.breadcrumbs$.pipe(
map( breadcrumbs => breadcrumbs[breadcrumbs.length - 1])
);
@ -63,20 +66,26 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
constructor(private router: Router,
private activatedRoute: ActivatedRoute,
private broadcast: BroadcastService,
private activeComponentService: ActiveComponentService,
private cd: ChangeDetectorRef,
private translate: TranslateService) {
private translate: TranslateService,
public utils: UtilsService) {
}
ngOnInit(): void {
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) {
@ -100,9 +109,7 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
let labelFunction;
let ignoreTranslate;
if (breadcrumbConfig.labelFunction) {
labelFunction = () => {
return breadcrumbConfig.labelFunction(route, this.translate, this.activeComponentValue, lastChild.data);
};
labelFunction = () => breadcrumbConfig.labelFunction(route, this.translate, this.activeComponentValue, lastChild.data);
ignoreTranslate = true;
} else {
label = breadcrumbConfig.label || 'home.home';

View File

@ -48,6 +48,7 @@ export * from './rule-node.models';
export * from './settings.models';
export * from './tenant.model';
export * from './user.model';
export * from './user-preferences.models';
export * from './widget.models';
export * from './widgets-bundle.model';
export * from './window-message.model';

View File

@ -0,0 +1,23 @@
///
/// Copyright © 2016-2023 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.
///
export interface UserPreferences {
openedMenuSections?: string[];
}
export const initialUserPreferences: UserPreferences = {
openedMenuSections: []
};

View File

@ -404,8 +404,11 @@
"generate-key": "Generate key",
"info-header": "All users will be to re-logined",
"info-message": "Change of the JWT Signing Key will cause all issued tokens to be invalid. All users will need to re-login. This will also affect scripts that use Rest API/Websockets."
}
},
},
"resources": "Resources",
"notifications": "Notifications",
"notifications-settings": "Notifications settings"
},
"alarm": {
"alarm": "Alarm",
"alarms": "Alarms",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -1701,6 +1701,11 @@
prop-types "^15.7.2"
react-is "^16.8.0 || ^17.0.0"
"@mdi/svg@^7.1.96":
version "7.1.96"
resolved "https://registry.yarnpkg.com/@mdi/svg/-/svg-7.1.96.tgz#4911ee6d2f3457f016be8b7a454cf1a8e5c361f0"
integrity sha512-QO+CyF7eZsYBJpyb9Q77r1O6PFdp/Ircx8FMV7+cFS7g0p5rF55PA9zrmzuZqi1LyPKANDpr0oULNLHgeQuXZQ==
"@ngrx/effects@^14.3.3":
version "14.3.3"
resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-14.3.3.tgz#5c9e43e4dc5e1d544f4604fc6a32feec2380c413"