Browse Source

UI: Add mobile bundles table

pull/11835/head
Vladyslav_Prykhodko 2 years ago
parent
commit
73e93c2968
  1. 2
      common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java
  2. 4
      common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java
  3. 13
      ui-ngx/src/app/core/http/entity.service.ts
  4. 26
      ui-ngx/src/app/core/http/mobile-app.service.ts
  5. 14
      ui-ngx/src/app/core/services/menu.models.ts
  6. 2
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.html
  7. 51
      ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts
  8. 11
      ui-ngx/src/app/modules/home/pages/mobile/applications/applications.module.ts
  9. 49
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html
  10. 81
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.ts
  11. 2
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.html
  12. 6
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts
  13. 48
      ui-ngx/src/app/modules/home/pages/mobile/bundes/bundles-routing.module.ts
  14. 49
      ui-ngx/src/app/modules/home/pages/mobile/bundes/bundles.module.ts
  15. 44
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.html
  16. 29
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.scss
  17. 53
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.ts
  18. 39
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.html
  19. 49
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.scss
  20. 64
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.ts
  21. 68
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html
  22. 22
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.scss
  23. 132
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.ts
  24. 64
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html
  25. 55
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.scss
  26. 120
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts
  27. 108
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html
  28. 190
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.scss
  29. 245
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.ts
  30. 75
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html
  31. 84
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.scss
  32. 323
      ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts
  33. 109
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html
  34. 73
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.scss
  35. 226
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.ts
  36. 142
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts
  37. 28
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.html
  38. 23
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.scss
  39. 37
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.ts
  40. 2
      ui-ngx/src/app/modules/home/pages/mobile/mobile-routing.module.ts
  41. 6
      ui-ngx/src/app/modules/home/pages/mobile/mobile.module.ts
  42. 6
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html
  43. 18
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts
  44. 2
      ui-ngx/src/app/shared/components/entity/entity-list.component.html
  45. 5
      ui-ngx/src/app/shared/components/entity/entity-list.component.ts
  46. 21
      ui-ngx/src/app/shared/models/entity-type.models.ts
  47. 189
      ui-ngx/src/app/shared/models/mobile-app.models.ts
  48. 55
      ui-ngx/src/assets/locale/locale.constant-en_US.json

2
common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java

@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.Views;
@Data
public abstract class AbstractMobilePage implements MobilePage {
@Schema(description = "Page label", example = "Air quality", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Page label", example = "Air quality")
@JsonView(Views.Public.class)
protected String label;
@Schema(description = "Indicates if page is visible", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)

4
common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java

@ -23,5 +23,7 @@ public enum DefaultPageId {
CUSTOMERS,
ASSETS,
AUDIT_LOGS,
NOTIFICATIONS
NOTIFICATIONS,
DEVICE_LIST,
DASHBOARDS
}

13
ui-ngx/src/app/core/http/entity.service.ts

@ -97,6 +97,8 @@ import { UserId } from '@shared/models/id/user-id';
import { AlarmService } from '@core/http/alarm.service';
import { ResourceService } from '@core/http/resource.service';
import { OAuth2Service } from '@core/http/oauth2.service';
import { MobileAppService } from '@core/http/mobile-app.service';
import { PlatformType } from '@shared/models/oauth2.models';
@Injectable({
providedIn: 'root'
@ -127,7 +129,8 @@ export class EntityService {
private notificationService: NotificationService,
private alarmService: AlarmService,
private resourceService: ResourceService,
private oauth2Service: OAuth2Service
private oauth2Service: OAuth2Service,
private mobileAppService: MobileAppService,
) { }
private getEntityObservable(entityType: EntityType, entityId: string,
@ -173,6 +176,10 @@ export class EntityService {
break;
case EntityType.QUEUE_STATS:
observable = this.queueService.getQueueStatisticsById(entityId, config);
break;
case EntityType.MOBILE_APP:
observable = this.mobileAppService.getMobileAppInfoById(entityId, config);
break;
}
return observable;
}
@ -462,6 +469,10 @@ export class EntityService {
pageLink.sortOrder.property = 'title';
entitiesObservable = this.oauth2Service.findTenantOAuth2ClientInfos(pageLink, config);
break;
case EntityType.MOBILE_APP:
pageLink.sortOrder.property = 'pkgName';
entitiesObservable = this.mobileAppService.getTenantMobileAppInfos(pageLink, subType as PlatformType, config);
break;
}
return entitiesObservable;
}

26
ui-ngx/src/app/core/http/mobile-app.service.ts

@ -21,6 +21,7 @@ import { Observable } from 'rxjs';
import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data';
import { MobileApp, MobileAppBundle, MobileAppBundleInfo } from '@shared/models/mobile-app.models';
import { PlatformType } from '@shared/models/oauth2.models';
@Injectable({
providedIn: 'root'
@ -36,8 +37,12 @@ export class MobileAppService {
return this.http.post<MobileApp>(`/api/mobile/app`, mobileApp, defaultHttpOptionsFromConfig(config));
}
public getTenantMobileAppInfos(pageLink: PageLink, config?: RequestConfig): Observable<PageData<MobileApp>> {
return this.http.get<PageData<MobileApp>>(`/api/mobile/app${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
public getTenantMobileAppInfos(pageLink: PageLink, platformType?: PlatformType, config?: RequestConfig): Observable<PageData<MobileApp>> {
let url = `/api/mobile/app${pageLink.toQuery()}`;
if (platformType) {
url += `&platformType=${platformType}`
}
return this.http.get<PageData<MobileApp>>(url, defaultHttpOptionsFromConfig(config));
}
public getMobileAppInfoById(id: string, config?: RequestConfig): Observable<MobileApp> {
@ -49,12 +54,15 @@ export class MobileAppService {
}
public saveMobileAppBundle(mobileAppBundle: MobileAppBundle, oauth2ClientIds?: Array<string>, config?: RequestConfig) {
return this.http.post<MobileApp>(`/api/mobile/bundle${oauth2ClientIds ? '?oauth2ClientIds=' + oauth2ClientIds.join(',') : ''}`,
mobileAppBundle, defaultHttpOptionsFromConfig(config));
let url = '/api/mobile/bundle';
if (oauth2ClientIds?.length) {
url += `?oauth2ClientIds=${oauth2ClientIds.join(',')}`;
}
return this.http.post<MobileAppBundle>(url, mobileAppBundle, defaultHttpOptionsFromConfig(config));
}
public updateOauth2Clients(id: string, oauth2ClientIds?: Array<string>, config?: RequestConfig) {
return this.http.put(`/mobile/bundle/${id}/oauth2Clients`, oauth2ClientIds, defaultHttpOptionsFromConfig(config));
public updateOauth2Clients(id: string, oauth2ClientIds: Array<string>, config?: RequestConfig) {
return this.http.put(`/api/mobile/bundle/${id}/oauth2Clients`, oauth2ClientIds, defaultHttpOptionsFromConfig(config));
}
public getTenantMobileAppBundleInfos(pageLink: PageLink, config?: RequestConfig): Observable<PageData<MobileAppBundleInfo>> {
@ -62,7 +70,11 @@ export class MobileAppService {
}
public getMobileAppBundleInfoById(id: string, config?: RequestConfig): Observable<MobileAppBundleInfo> {
return this.http.get<MobileAppBundleInfo>(`/api/mobile/bundle/infos/${id}`, defaultHttpOptionsFromConfig(config));
return this.http.get<MobileAppBundleInfo>(`/api/mobile/bundle/info/${id}`, defaultHttpOptionsFromConfig(config));
}
public deleteMobileAppBundle(id: string, config?: RequestConfig): Observable<void> {
return this.http.delete<void>(`/api/mobile/bundle/${id}`, defaultHttpOptionsFromConfig(config));
}
}

14
ui-ngx/src/app/core/services/menu.models.ts

@ -67,6 +67,7 @@ export enum MenuId {
notification_rules = 'notification_rules',
mobile_center = 'mobile_center',
mobile_apps = 'mobile_apps',
mobile_bundles = 'mobile_bundles',
mobile_app_settings = 'mobile_app_settings',
settings = 'settings',
general = 'general',
@ -292,6 +293,16 @@ export const menuSectionMap = new Map<MenuId, MenuSection>([
icon: 'list'
}
],
[
MenuId.mobile_bundles,
{
id: MenuId.mobile_bundles,
name: 'mobile.bundles',
type: 'link',
path: '/mobile-center/bundles',
icon: 'mdi:package'
}
],
[
MenuId.mobile_app_settings,
{
@ -702,6 +713,7 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
id: MenuId.mobile_center,
pages: [
{id: MenuId.mobile_apps},
{id: MenuId.mobile_bundles},
{id: MenuId.mobile_app_settings}
]
},
@ -850,7 +862,7 @@ const defaultHomeSectionMap = new Map<Authority, HomeSectionReference[]>([
{
name: 'admin.system-settings',
places: [MenuId.general, MenuId.mail_server,
MenuId.notification_settings, MenuId.security_settings, MenuId.oauth2, MenuId.domains, MenuId.mobile_apps,
MenuId.notification_settings, MenuId.security_settings, MenuId.oauth2, MenuId.domains,
MenuId.clients, MenuId.two_fa, MenuId.resources_library, MenuId.queues]
}
]

2
ui-ngx/src/app/modules/home/components/entity/entities-table.component.html

@ -173,7 +173,7 @@
(click)="$event.stopPropagation();"></a>
</ng-template>
<ng-template [ngSwitchCase]="'entityChips'">
<tb-entity-chips [entity]="entity"></tb-entity-chips>
<tb-entity-chips [entity]="entity" [key]="column.key"></tb-entity-chips>
</ng-template>
<ng-template #defaultContent ngSwitchDefault>
<span [innerHTML]="cellContent(entity, column, row)"></span>

51
ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts

@ -14,48 +14,49 @@
/// limitations under the License.
///
import { Component, Input } from '@angular/core';
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { BaseData } from '@shared/models/base-data';
import { EntityId } from '@shared/models/id/entity-id';
import { baseDetailsPageByEntityType, EntityType } from '@app/shared/public-api';
const entityTypeEntitiesPropertyKeyMap = new Map<EntityType, string>([
[EntityType.DOMAIN, 'oauth2ClientInfos'],
[EntityType.MOBILE_APP, 'oauth2ClientInfos']
]);
import { isObject } from '@core/utils';
@Component({
selector: 'tb-entity-chips',
templateUrl: './entity-chips.component.html',
styleUrls: ['./entity-chips.component.scss']
})
export class EntityChipsComponent {
export class EntityChipsComponent implements OnChanges {
@Input()
set entity(value: BaseData<EntityId>) {
this.entityValue = value;
this.update();
}
entity: BaseData<EntityId>;
get entity(): BaseData<EntityId> {
return this.entityValue;
}
@Input()
key: string;
entityDetailsPrefixUrl: string;
subEntities: Array<BaseData<EntityId>>;
private entityValue?: BaseData<EntityId>;
subEntities: Array<BaseData<EntityId>> = [];
private subEntitiesKey: string;
ngOnChanges(changes: SimpleChanges) {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (propName === 'entity' && change.currentValue !== change.previousValue) {
this.update();
}
}
}
update(): void {
if (this.entity && this.entity.id) {
const entityType = this.entity.id.entityType as EntityType;
this.subEntitiesKey = entityTypeEntitiesPropertyKeyMap.get(entityType);
this.subEntities = this.entity?.[this.subEntitiesKey];
if (this.subEntities.length) {
this.entityDetailsPrefixUrl = baseDetailsPageByEntityType.get(this.subEntities[0].id.entityType as EntityType);
private update(): void {
if (this.entity && this.entity.id && this.key) {
let entitiesList = this.entity?.[this.key];
if (isObject(entitiesList) && !Array.isArray(entitiesList)) {
entitiesList = [entitiesList];
}
if (Array.isArray(entitiesList)) {
this.subEntities = entitiesList;
if (this.subEntities.length) {
this.entityDetailsPrefixUrl = baseDetailsPageByEntityType.get(this.subEntities[0].id.entityType as EntityType);
}
}
}
}

11
ui-ngx/src/app/modules/home/pages/mobile/applications/applications.module.ts

@ -22,18 +22,23 @@ import { SharedModule } from '@shared/shared.module';
import { HomeComponentsModule } from '@home/components/home-components.module';
import { ApplicationsRoutingModule } from '@home/pages/mobile/applications/applications-routing.module';
import { ReleaseNotesPanelComponent } from '@home/pages/mobile/applications/release-notes-panel.component';
import { MobileAppDialogComponent } from '@home/pages/mobile/applications/mobile-app-dialog.component';
@NgModule({
declarations: [
MobileAppComponent,
MobileAppTableHeaderComponent,
ReleaseNotesPanelComponent
ReleaseNotesPanelComponent,
MobileAppDialogComponent,
],
imports: [
CommonModule,
SharedModule,
HomeComponentsModule,
ApplicationsRoutingModule
ApplicationsRoutingModule,
],
exports: [
MobileAppDialogComponent,
]
})
export class ApplicationModule { }
export class MobileApplicationModule { }

49
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html

@ -0,0 +1,49 @@
<!--
Copyright © 2016-2024 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.
-->
<form (ngSubmit)="save()" style="width: 850px;max-height: 100vh;display: grid;grid-template-rows: min-content 4px minmax(auto, 1fr) min-content;">
<mat-toolbar color="primary">
<h2>{{ 'mobile.add-application' | translate }}</h2>
<span fxFlex></span>
<div tb-help="mobileApplication"></div>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content style="padding: 0">
<tb-mobile-app #mobileAppComponent></tb-mobile-app>
</div>
<div mat-dialog-actions fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
cdkFocusInitial
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || mobileAppComponent.entityForm?.invalid || !mobileAppComponent.entityForm?.dirty">
{{ 'action.add' | translate }}
</button>
</div>
</form>

81
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.ts

@ -0,0 +1,81 @@
///
/// Copyright © 2016-2024 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, OnDestroy, SkipSelf, ViewChild } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { MobileApp } from '@shared/models/mobile-app.models';
import { MobileAppComponent } from '@home/pages/mobile/applications/mobile-app.component';
import { PlatformType } from '@shared/models/oauth2.models';
import { MobileAppService } from '@core/http/mobile-app.service';
export interface MobileAppDialogData {
platformType: PlatformType;
}
@Component({
selector: 'tb-mobile-app-dialog',
templateUrl: './mobile-app-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: MobileAppDialogComponent}],
styleUrls: []
})
export class MobileAppDialogComponent extends DialogComponent<MobileAppDialogComponent, MobileApp> implements OnDestroy, AfterViewInit, ErrorStateMatcher {
submitted = false;
@ViewChild('mobileAppComponent', {static: true}) mobileAppComponent: MobileAppComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<MobileAppDialogComponent, MobileApp>,
private mobileAppService: MobileAppService,
@Inject(MAT_DIALOG_DATA) public data: MobileAppDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher) {
super(store, router, dialogRef);
}
ngAfterViewInit(): void {
setTimeout(() => {
this.mobileAppComponent.entityForm.markAsDirty();
this.mobileAppComponent.entityForm.patchValue({platformType: this.data.platformType});
this.mobileAppComponent.entityForm.get('platformType').disable({emitEvent: false});
}, 0);
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close(null);
}
save() {
this.submitted = true;
if (this.mobileAppComponent.entityForm.valid) {
this.mobileAppService.saveMobileApp(this.mobileAppComponent.entityFormValue()).subscribe(
app => this.dialogRef.close(app)
)
}
}
}

2
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.html

@ -18,7 +18,7 @@
<div fxFlex="100" fxLayout="row" fxLayoutAlign="space-between center" fxLayoutAlign.xs="start center">
<section fxHide fxShow.gt-xs class="tb-entity-title-container">
<div tbTruncateWithTooltip translate>mobile.applications</div>
<!-- <div tb-help="oauth2Settings"></div>-->
<div tb-help="mobileApplication"></div>
</section>
<button mat-stroked-button color="primary" (click)="createMobile($event)">
<div fxLayout="row" fxLayoutAlign="start center">

6
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Inject, Renderer2, ViewContainerRef } from '@angular/core';
import { ChangeDetectorRef, Component, Inject, Optional, Renderer2, ViewContainerRef } from '@angular/core';
import { EntityComponent } from '@home/components/entity/entity.component';
import { AppState } from '@core/core.state';
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
@ -51,8 +51,8 @@ export class MobileAppComponent extends EntityComponent<MobileApp> {
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: MobileApp,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<MobileApp>,
@Optional() @Inject('entity') protected entityValue: MobileApp,
@Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<MobileApp>,
protected cd: ChangeDetectorRef,
public fb: FormBuilder,
private popoverService: TbPopoverService,

48
ui-ngx/src/app/modules/home/pages/mobile/bundes/bundles-routing.module.ts

@ -0,0 +1,48 @@
///
/// Copyright © 2016-2024 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 { Routes } from '@angular/router';
import { EntitiesTableComponent } from '@home/components/entity/entities-table.component';
import { Authority } from '@shared/models/authority.enum';
import { MenuId } from '@core/services/menu.models';
import { MobileBundleTableConfigResolver } from '@home/pages/mobile/bundes/mobile-bundle-table-config.resolve';
import { NgModule } from '@angular/core';
export const bundlesRoutes: Routes = [
{
path: 'bundles',
component: EntitiesTableComponent,
data: {
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'mobile.bundles',
breadcrumb: {
menuId: MenuId.mobile_bundles
}
},
resolve: {
entitiesTableConfig: MobileBundleTableConfigResolver
}
}
];
@NgModule({
providers: [
MobileBundleTableConfigResolver
],
imports: [],
exports: []
})
export class MobileBundleRoutingModule { }

49
ui-ngx/src/app/modules/home/pages/mobile/bundes/bundles.module.ts

@ -0,0 +1,49 @@
///
/// Copyright © 2016-2024 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 '@shared/shared.module';
import { HomeComponentsModule } from '@home/components/home-components.module';
import { MobileBundleRoutingModule } from '@home/pages/mobile/bundes/bundles-routing.module';
import { MobileBundleTableHeaderComponent } from '@home/pages/mobile/bundes/mobile-bundle-table-header.component';
import { MobileBundleDialogComponent } from '@home/pages/mobile/bundes/mobile-bundle-dialog.component';
import { MobileLayoutComponent } from '@home/pages/mobile/bundes/layout/mobile-layout.component';
import { MobilePageItemRowComponent } from '@home/pages/mobile/bundes/layout/mobile-page-item-row.component';
import { AddMobilePageDialogComponent } from '@home/pages/mobile/bundes/layout/add-mobile-page-dialog.component';
import { CustomMobilePageComponent } from '@home/pages/mobile/bundes/layout/custom-mobile-page.component';
import { CustomMobilePagePanelComponent } from '@home/pages/mobile/bundes/layout/custom-mobile-page-panel.component';
import { DefaultMobilePagePanelComponent } from '@home/pages/mobile/bundes/layout/default-mobile-page-panel.component';
@NgModule({
declarations: [
MobileBundleTableHeaderComponent,
MobileBundleDialogComponent,
MobileLayoutComponent,
MobilePageItemRowComponent,
AddMobilePageDialogComponent,
CustomMobilePageComponent,
CustomMobilePagePanelComponent,
DefaultMobilePagePanelComponent,
],
imports: [
CommonModule,
SharedModule,
HomeComponentsModule,
MobileBundleRoutingModule,
]
})
export class MobileBundlesModule { }

44
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.html

@ -0,0 +1,44 @@
<!--
Copyright © 2016-2024 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.
-->
<mat-toolbar color="primary">
<h2 translate>mobile.add-specific-page</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content fxLayout="column">
<tb-mobile-page-item [formControl]="customMobilePage">
</tb-mobile-page-item>
</div>
<div mat-dialog-actions fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="button"
[disabled]="(isLoading$ | async) || customMobilePage.invalid || !customMobilePage.dirty"
(click)="save()">
{{ 'action.add' | translate }}
</button>
</div>

29
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.scss

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2024 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 {
width: 600px;
height: 100%;
max-width: 100%;
max-height: 100vh;
display: grid;
grid-template-rows: min-content minmax(auto, 1fr) min-content;
.mobile-page-form {
display: flex;
flex-direction: column;
gap: 16px;
}
}

53
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.ts

@ -0,0 +1,53 @@
///
/// Copyright © 2016-2024 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 { Component } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { CustomMobilePage } from '@shared/models/mobile-app.models';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog';
import { FormBuilder } from '@angular/forms';
import { deepTrim } from '@core/utils';
@Component({
selector: 'tb-add-mobile-page-dialog',
templateUrl: './add-mobile-page-dialog.component.html',
styleUrls: ['./add-mobile-page-dialog.component.scss']
})
export class AddMobilePageDialogComponent extends DialogComponent<AddMobilePageDialogComponent, CustomMobilePage> {
customMobilePage = this.fb.control<CustomMobilePage>(null);
constructor(protected store: Store<AppState>,
protected router: Router,
public dialogRef: MatDialogRef<AddMobilePageDialogComponent, CustomMobilePage>,
private fb: FormBuilder) {
super(store, router, dialogRef);
}
cancel(): void {
this.dialogRef.close(null);
}
save() {
if (this.customMobilePage.valid) {
const pageItem = deepTrim(this.customMobilePage.value);
this.dialogRef.close(pageItem);
}
}
}

39
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.html

@ -0,0 +1,39 @@
<!--
Copyright © 2016-2024 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 class="tb-custom-mobile-page-panel">
<div class="tb-custom-mobile-page-title" translate>mobile.edit-custom-page</div>
<div class="tb-custom-mobile-page-panel-content">
<tb-mobile-page-item [formControl]="mobilePageControl">
</tb-mobile-page-item>
</div>
<div class="tb-custom-mobile-page-panel-buttons">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="apply()"
[disabled]="mobilePageControl.invalid || !mobilePageControl.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

49
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.scss

@ -0,0 +1,49 @@
/**
* Copyright © 2016-2024 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 '../scss/constants';
.tb-custom-mobile-page-panel {
width: 500px;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-lt-md} {
width: 90vw;
}
.tb-custom-mobile-page-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-custom-mobile-page-panel-content {
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
margin: -10px;
padding: 10px;
}
.tb-custom-mobile-page-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
}

64
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.ts

@ -0,0 +1,64 @@
///
/// Copyright © 2016-2024 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 { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { CustomMobilePage } from '@shared/models/mobile-app.models';
import { TbPopoverComponent } from '@shared/components/popover.component';
@Component({
selector: 'tb-custom-menu-item-panel',
templateUrl: './custom-mobile-page-panel.component.html',
styleUrls: ['./custom-mobile-page-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class CustomMobilePagePanelComponent implements OnInit {
@Input()
disabled: boolean;
@Input()
pageItem: CustomMobilePage;
@Input()
popover: TbPopoverComponent<CustomMobilePagePanelComponent>;
@Output()
customMobilePageApplied = new EventEmitter<CustomMobilePage>();
mobilePageControl = this.fb.control<CustomMobilePage>(null);
constructor(private fb: FormBuilder) {
}
ngOnInit() {
this.mobilePageControl.setValue(this.pageItem, {emitEvent: false});
if (this.disabled) {
this.mobilePageControl.disable({emitEvent: false});
}
}
cancel() {
this.popover?.hide();
}
apply() {
if (this.mobilePageControl.valid) {
const menuItem = this.mobilePageControl.value;
this.customMobilePageApplied.emit(menuItem);
}
}
}

68
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html

@ -0,0 +1,68 @@
<!--
Copyright © 2016-2024 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.
-->
<section [formGroup]="customMobilePageForm" class="mobile-page-form">
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="visible">
{{ 'mobile.visible' | translate }}
</mat-slide-toggle>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="center center">
<tb-material-icon-select asBoxInput
required
color="#305680"
formControlName="icon">
</tb-material-icon-select>
<mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic">
<mat-label translate>mobile.page-name</mat-label>
<input required matInput formControlName="label">
</mat-form-field>
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label translate>mobile.page-type</mat-label>
<mat-select formControlName="type">
<mat-option *ngFor="let pageType of mobilePagesTypes" [value]="pageType">
{{ mobilePageTypeTranslations.get(pageType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<ng-container *ngIf="customMobilePageForm.get('type').value === MobilePageType.DASHBOARD">
<tb-dashboard-autocomplete fxFlex
formControlName="dashboardId"
required
label="{{ 'dashboard.dashboard' | translate }}"
floatLabel="always"
appearance="outline"
subscriptSizing="dynamic"
requiredText="dashboard.dashboard-required"
placeholder="{{ 'dashboard.select-dashboard' | translate }}"
[selectFirstDashboard]="false"
></tb-dashboard-autocomplete>
</ng-container>
<ng-container *ngIf="customMobilePageForm.get('type').value === MobilePageType.WEB_VIEW">
<mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic">
<mat-label translate>mobile.url</mat-label>
<input required matInput formControlName="url">
</mat-form-field>
</ng-container>
<ng-container *ngIf="customMobilePageForm.get('type').value === MobilePageType.CUSTOM">
<mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic">
<mat-label translate>mobile.path</mat-label>
<input required matInput formControlName="path">
</mat-form-field>
</ng-container>
</section>

22
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.scss

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2024 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{
.mobile-page-form {
display: flex;
flex-direction: column;
gap: 16px;
}
}

132
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.ts

@ -0,0 +1,132 @@
///
/// Copyright © 2016-2024 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 { booleanAttribute, Component, forwardRef, Input } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { CustomMobilePage, MobilePageType, mobilePageTypeTranslations } from '@shared/models/mobile-app.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { Authority } from '@shared/models/authority.enum';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@Component({
selector: 'tb-mobile-page-item',
templateUrl: './custom-mobile-page.component.html',
styleUrls: ['./custom-mobile-page.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomMobilePageComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CustomMobilePageComponent),
multi: true
}
]
})
export class CustomMobilePageComponent implements ControlValueAccessor, Validator {
@Input({transform: booleanAttribute})
disabled: boolean;
mobilePagesTypes = [MobilePageType.DASHBOARD, MobilePageType.WEB_VIEW, MobilePageType.CUSTOM];
MobilePageType = MobilePageType;
mobilePageTypeTranslations = mobilePageTypeTranslations;
customMobilePageForm = this.fb.group({
visible: [true],
icon: ['star'],
label: ['', Validators.required],
type: [MobilePageType.DASHBOARD],
dashboardId: ['', Validators.required],
url: [{value:'', disabled: true}, [Validators.required]],
path: [{value:'', disabled: true}, [Validators.required, Validators.pattern(/^((?!\s).)*$/)]]
});
private propagateChange = (_val: any) => {};
constructor(private fb: FormBuilder,
private store: Store<AppState>) {
this.customMobilePageForm.get('type').valueChanges.pipe(
takeUntilDestroyed()
).subscribe(type => {
this.customMobilePageForm.get('dashboardId').disable({emitEvent: false});
this.customMobilePageForm.get('url').disable({emitEvent: false});
this.customMobilePageForm.get('path').disable({emitEvent: false});
switch (type) {
case MobilePageType.DASHBOARD:
this.customMobilePageForm.get('dashboardId').enable({emitEvent: false});
break;
case MobilePageType.WEB_VIEW:
this.customMobilePageForm.get('url').enable({emitEvent: false});
break;
case MobilePageType.CUSTOM:
this.customMobilePageForm.get('path').enable({emitEvent: false});
break;
}
});
if (getCurrentAuthUser(this.store).authority === Authority.SYS_ADMIN) {
this.mobilePagesTypes.shift();
this.customMobilePageForm.get('type').setValue(MobilePageType.WEB_VIEW);
}
this.customMobilePageForm.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value) => this.propagateChange(value))
}
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled;
if (isDisabled) {
this.customMobilePageForm.disable({emitEvent: false});
} else {
this.customMobilePageForm.enable({emitEvent: false});
this.customMobilePageForm.get('type').updateValueAndValidity({onlySelf: true});
}
}
validate(): ValidationErrors | null {
if (!this.customMobilePageForm.valid) {
return {
invalidCustomMobilePageForm: true
};
}
return null;
}
writeValue(value: CustomMobilePage) {
this.customMobilePageForm.patchValue(value, {emitEvent: false});
}
}

64
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html

@ -0,0 +1,64 @@
<!--
Copyright © 2016-2024 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 class="tb-default-mobile-page-panel" [formGroup]="mobilePageFormGroup">
<div class="tb-default-mobile-page-title-container">
<div class="tb-default-mobile-page-title" translate>mobile.edit-page</div>
<button *ngIf="!disabled"
[disabled]="!isCleanupEnabled"
type="button"
mat-stroked-button
class="tb-box-button"
matTooltip="{{ 'mobile.reset-to-page-default' | translate }}"
matTooltipPosition="above"
(click)="cleanup()">
<tb-icon [color]="isCleanupEnabled ? 'primary' : null" matButtonIcon>mdi:broom</tb-icon>
</button>
</div>
<div class="tb-default-mobile-page-panel-content">
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="visible">
{{ 'mobile.visible' | translate }}
</mat-slide-toggle>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="center center">
<tb-material-icon-select asBoxInput
color="#305680"
formControlName="icon">
</tb-material-icon-select>
<mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic" floatLabel="always">
<mat-label translate>mobile.page-name</mat-label>
<input matInput formControlName="label" placeholder="{{ defaultItemName }}">
</mat-form-field>
</div>
</div>
<div class="tb-default-mobile-page-panel-buttons">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="apply()"
[disabled]="mobilePageFormGroup.invalid || !mobilePageFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

55
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.scss

@ -0,0 +1,55 @@
/**
* Copyright © 2016-2024 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 '../scss/constants';
.tb-default-mobile-page-panel {
width: 500px;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-lt-md} {
width: 90vw;
}
.tb-default-mobile-page-title-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.tb-default-mobile-page-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-default-mobile-page-panel-content {
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
margin: -10px;
padding: 10px;
}
.tb-default-mobile-page-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
}

120
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts

@ -0,0 +1,120 @@
///
/// Copyright © 2016-2024 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 { Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { DefaultMobilePage, defaultMobilePageMap, hideDefaultMenuItems } from '@shared/models/mobile-app.models';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'tb-default-mobile-page-panel',
templateUrl: './default-mobile-page-panel.component.html',
styleUrls: ['./default-mobile-page-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class DefaultMobilePagePanelComponent implements OnInit {
@Input()
disabled: boolean;
@Input()
pageItem: DefaultMobilePage;
@Input()
popover: TbPopoverComponent<DefaultMobilePagePanelComponent>;
@Output()
defaultMobilePageApplied = new EventEmitter<DefaultMobilePage>();
mobilePageFormGroup = this.fb.group({
visible: [true],
icon: [''],
label: [''],
});
isCleanupEnabled = false;
defaultItemName: string;
private defaultMobilePages: Omit<DefaultMobilePage, 'type' | 'visible'>;
private destroyRef = inject(DestroyRef);
constructor(private fb: FormBuilder) {
}
ngOnInit() {
this.defaultMobilePages = defaultMobilePageMap.get(this.pageItem.id);
this.defaultItemName = this.defaultMobilePages.label;
this.mobilePageFormGroup.patchValue({
label: this.pageItem.label,
icon: this.pageItem.icon ? this.pageItem.icon : this.defaultMobilePages.icon,
visible: this.pageItem.visible
}, {emitEvent: false});
if (this.disabled) {
this.mobilePageFormGroup.disable({emitEvent: false});
} else {
this.mobilePageFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
this.updateCleanupState();
}
}
cancel() {
this.popover?.hide();
}
apply() {
this.defaultMobilePageApplied.emit(this.pageItem);
}
cleanup() {
this.mobilePageFormGroup.patchValue({
visible: !hideDefaultMenuItems.includes(this.pageItem.id),
icon: this.defaultMobilePages.icon,
label: null
}, {emitEvent: false});
this.mobilePageFormGroup.markAsDirty();
this.updateModel();
}
private updateCleanupState() {
this.isCleanupEnabled = (hideDefaultMenuItems.includes(this.pageItem.id) ? this.pageItem.visible : !this.pageItem.visible) ||
!!this.pageItem.label ||
!!this.pageItem.icon;
}
private updateModel() {
this.pageItem.visible = this.mobilePageFormGroup.get('visible').value;
const label = this.mobilePageFormGroup.get('label').value;
if (label) {
this.pageItem.label = label;
} else {
delete this.pageItem.label;
}
const icon = this.mobilePageFormGroup.get('icon').value;
if (icon !== this.defaultMobilePages.icon) {
this.pageItem.icon = icon;
} else {
delete this.pageItem.icon;
}
this.updateCleanupState();
}
}

108
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html

@ -0,0 +1,108 @@
<!--
Copyright © 2016-2024 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 class="mobile-layout-container">
<div class="header">
<div class="title-section">
<span tbTruncateWithTooltip class="title-container" translate>mobile.pages</span>
</div>
<span fxFlex></span>
<button *ngIf="!readonly" class="tb-mobile-layout-button"
fxHide.lt-lg
mat-stroked-button
color="primary"
(click)="hideAll()">
<tb-icon matButtonIcon>visibility_off</tb-icon>
<div tbTruncateWithTooltip>{{ 'mobile.hide-all-pages' | translate }}</div>
</button>
<button *ngIf="!readonly" class="tb-mobile-layout-button"
fxHide.lt-lg
mat-stroked-button
color="primary"
(click)="resetToDefault()">
<tb-icon matButtonIcon>mdi:broom</tb-icon>
<div tbTruncateWithTooltip>{{ 'mobile.reset-to-default-pages' | translate }}</div>
</button>
<button *ngIf="!readonly"
fxHide.gt-md
type="button"
mat-stroked-button
class="tb-box-button"
matTooltip="{{ 'mobile.hide-all-pages' | translate }}"
matTooltipPosition="above"
(click)="hideAll()">
<tb-icon color="primary" matButtonIcon>visibility_off</tb-icon>
</button>
<button *ngIf="!readonly"
fxHide.gt-md
type="button"
mat-stroked-button
class="tb-box-button"
matTooltip="{{ 'mobile.reset-to-default-pages' | translate }}"
matTooltipPosition="above"
(click)="resetToDefault()">
<tb-icon color="primary" matButtonIcon>mdi:broom</tb-icon>
</button>
<div tb-help="mobileLayout"></div>
</div>
<div #mobilePagesContainer class="tb-drop-list tb-mobile-items-container"
cdkDropList
cdkDropListOrientation="vertical"
(cdkDropListDropped)="mobileItemDrop($event)"
[cdkDropListDisabled]="!dragEnabled">
<div class="tb-mobile-item-row-container" *ngFor="let menuItemControl of visibleMobilePagesControls(); trackBy: trackByMenuItem;
let $index = index; last as isLast;">
<div cdkDrag [cdkDragDisabled]="!dragEnabled"
class="tb-draggable tb-mobile-item-row">
<div *ngIf="isCustom(menuItemControl)" class="tb-mobile-item-label">
{{ 'mobile.custom-page' | translate }}
</div>
<button cdkDragHandle *ngIf="dragEnabled"
mat-icon-button
matTooltip="{{ 'action.drag' | translate }}"
matTooltipPosition="above"
class="tb-mat-40 tb-drag-handle">
<tb-icon>drag_indicator</tb-icon>
</button>
<tb-mobile-menu-item-row style="flex: 1; min-width: 0;"
[formControl]="menuItemControl"
[maxIconNameBlockWidth]="maxIconNameBlockWidth"
[hideItems]="hideItems$"
(pageRemoved)="removeCustomPage($index)">
</tb-mobile-menu-item-row>
</div>
<button *ngIf="!isLast && !readonly"
(click)="addCustomMobilePage($index)"
class="tb-add-mobile-item-button"
[class.divider]="showMenuDivider($index)"
mat-button
matTooltip="{{ 'mobile.add-specific-page' | translate }}"
matTooltipPosition="above"
color="primary">
<div class="tb-add-mobile-label-container tb-primary-fill">
<mat-icon class="tb-mat-18" color="primary">add</mat-icon>
<span class="tb-mobile-divider-label" *ngIf="showMenuDivider($index)">{{ getDividerLabel($index) | translate }}</span>
</div>
</button>
</div>
</div>
<button *ngIf="!readonly" (click)="addCustomMobilePage()"
mat-button color="primary" class="tb-primary-fill" style="min-height: 52px;">
<mat-icon color="primary">add</mat-icon>
<span translate>mobile.add-specific-page</span>
</button>
</div>

190
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.scss

@ -0,0 +1,190 @@
/**
* Copyright © 2016-2024 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 './../scss/constants';
:host{
.mobile-layout-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.header {
z-index: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
.title-section {
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
min-width: 0;
.title-container {
font-size: 20px;
letter-spacing: .1px;
}
}
}
.tb-mobile-items-container {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: auto;
padding-top: 8px;
padding-bottom: 8px;
}
}
.tb-mobile-layout-button.mdc-button {
min-width: 120px;
tb-icon {
min-width: 18px;
}
.mdc-button__label {
overflow: hidden;
}
}
::ng-deep {
.mat-mdc-button.mat-mdc-button-base.tb-add-mobile-item-button {
height: 24px;
opacity: 0;
transition: all 200ms;
&.divider {
opacity: 1;
.mat-mdc-button-persistent-ripple {
border-bottom-style: dashed;
border-bottom-width: 1px;
}
.tb-add-mobile-label-container {
vertical-align: middle;
.mat-icon {
display: none;
}
}
&:hover {
.mat-mdc-button-persistent-ripple {
border-bottom-style: inherit;
border-bottom-width: inherit;
}
.tb-add-mobile-label-container {
vertical-align: sub;
.mat-icon {
display: inline-block;
}
.tb-mobile-divider-label {
display: none;
}
}
}
.mat-mdc-button-persistent-ripple {
top: 11px;
bottom: 11px;
&:before {
opacity: 0.32;
}
}
}
&:hover {
opacity: 1;
}
.mat-mdc-button-persistent-ripple {
top: 11px;
bottom: 11px;
&:before {
opacity: 0.32;
}
}
&:active {
.mat-mdc-button-persistent-ripple {
&:before {
opacity: 0.48;
}
}
.tb-add-mobile-label-container {
&:after {
opacity: 0.48;
}
}
}
.tb-add-mobile-label-container {
display: inline-flex;
padding: 3px;
vertical-align: sub;
border-radius: 6px;
background: #fff;
.mat-icon {
margin: 0;
}
.tb-mobile-divider-label {
line-height: 12px;
font-size: 10px;
font-weight: 400;
padding: 0 6px;
}
&:after {
display: block;
height: auto;
content: "";
position: absolute;
inset: 0;
opacity: 0.32;
border-radius: 6px;
border: 1px solid $tb-primary-color;
pointer-events: none;
}
}
}
}
}
.tb-mobile-item-row-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.tb-mobile-item-row {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 12px 12px 0;
background: #fff;
gap: 0;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 5px 16px 0 rgba(0, 0, 0, 0.04);
.tb-mobile-item-label {
position: absolute;
left: 8px;
top: -8px;
padding-left: 8px;
padding-right: 8px;
background: #fff;
font-size: 12px;
font-weight: 400;
line-height: 16px;
letter-spacing: 0.4px;
color: rgba(0, 0, 0, 0.54);
}
}

245
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.ts

@ -0,0 +1,245 @@
///
/// Copyright © 2016-2024 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 { booleanAttribute, Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormBuilder,
FormControl,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
Validator
} from '@angular/forms';
import {
CustomMobilePage,
getDefaultMobileMenuItem,
isDefaultMobilePagesConfig,
MobileLayoutConfig,
mobileMenuDividers,
MobilePage,
MobilePageType
} from '@shared/models/mobile-app.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { deepClone, isDefined } from '@core/utils';
import { Subject } from 'rxjs';
import { BreakpointObserver } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants';
import { MatDialog } from '@angular/material/dialog';
import { AddMobilePageDialogComponent } from '@home/pages/mobile/bundes/layout/add-mobile-page-dialog.component';
@Component({
selector: 'tb-mobile-layout',
templateUrl: './mobile-layout.component.html',
styleUrls: ['./mobile-layout.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MobileLayoutComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => MobileLayoutComponent),
multi: true
}
],
})
export class MobileLayoutComponent implements ControlValueAccessor, Validator {
@Input({transform: booleanAttribute})
readonly!: boolean
@ViewChild('mobilePagesContainer')
mobilePagesContainer: ElementRef<HTMLElement>;
pagesForm = this.fb.group({
pages: this.fb.array([])
});
maxIconNameBlockWidth = 256;
private hideItemsSubject = new Subject<void>();
hideItems$ = this.hideItemsSubject.asObservable();
private propagateChange = (_val: any) => {};
constructor(private fb: FormBuilder,
private breakpointObserver: BreakpointObserver,
private dialog: MatDialog,
) {
this.pagesForm.valueChanges.pipe(
takeUntilDestroyed()
).subscribe(
() => this.updateModel()
);
this.breakpointObserver.observe([MediaBreakpoints.xs, MediaBreakpoints['gt-xs'], MediaBreakpoints['gt-sm']]).pipe(
takeUntilDestroyed()
).subscribe(() => {
this.computeMaxIconNameBlockWidth();
});
this.computeMaxIconNameBlockWidth();
}
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(_fn: any) {
}
setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.pagesForm.disable({emitEvent: false});
} else {
this.pagesForm.enable({emitEvent: false});
}
}
writeValue(layout: MobileLayoutConfig) {
const processLayout = this.prepareMobilePages(layout);
this.pagesForm.setControl('pages', this.prepareMobilePagesFormArray(processLayout));
}
validate(_c: FormControl) {
if (!this.pagesForm.valid) {
return {
invalidLayoutForm: true
};
}
return null;
}
hideAll() {
this.hideItemsSubject.next();
}
resetToDefault() {
if (!isDefaultMobilePagesConfig(this.pagesForm.value.pages as MobilePage[])) {
const processLayout = this.prepareMobilePages(null);
this.pagesForm.setControl('pages', this.prepareMobilePagesFormArray(processLayout));
}
}
get dragEnabled(): boolean {
return !this.readonly && this.visibleMobilePagesControls().length > 1;
}
visibleMobilePagesControls(): Array<AbstractControl> {
return this.pagesFormArray().controls;
}
mobileItemDrop(event: CdkDragDrop<string[]>) {
const menuItemsArray = this.pagesFormArray();
const menuItem = this.visibleMobilePagesControls()[event.previousIndex];
const previousIndex = this.actualMobilePageIndex(event.previousIndex);
const currentIndex = this.actualMobilePageIndex(event.currentIndex);
menuItemsArray.removeAt(previousIndex);
menuItemsArray.insert(currentIndex, menuItem);
this.pagesForm.markAsDirty();
}
trackByMenuItem(_index: number, menuItemControl: AbstractControl): any {
return menuItemControl;
}
addCustomMobilePage(index?: number) {
this.dialog.open<AddMobilePageDialogComponent, null,
CustomMobilePage>(AddMobilePageDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog']
}).afterClosed().subscribe((menuItem) => {
if (menuItem) {
const menuItemsArray = this.pagesFormArray();
const menuItemControl = this.fb.control(menuItem, []);
if (isDefined(index)) {
const insertIndex = this.actualMobilePageIndex(index) + 1;
menuItemsArray.insert(insertIndex, menuItemControl);
} else {
menuItemsArray.push(menuItemControl);
setTimeout(() => {
this.mobilePagesContainer.nativeElement.scrollTop = this.mobilePagesContainer.nativeElement.scrollHeight;
}, 0);
}
this.pagesForm.markAsDirty();
}
});
}
isCustom(menuItemControl: AbstractControl): boolean {
return menuItemControl.value.type !== MobilePageType.DEFAULT;
}
removeCustomPage(index: number) {
this.pagesFormArray().removeAt(this.actualMobilePageIndex(index));
this.pagesForm.markAsDirty();
}
showMenuDivider(index: number): boolean {
return mobileMenuDividers.has(index);
}
getDividerLabel(index: number): string {
return mobileMenuDividers.get(index);
}
private updateModel() {
if (isDefaultMobilePagesConfig(this.pagesForm.value.pages as MobilePage[])) {
this.propagateChange({pages: []});
} else {
this.propagateChange(this.pagesForm.value);
}
}
private prepareMobilePages(layout: MobileLayoutConfig) {
if (!layout?.pages?.length) {
return getDefaultMobileMenuItem();
}
return layout.pages;
}
private prepareMobilePagesFormArray(items: MobilePage[]): FormArray {
const menuItemsControls: Array<AbstractControl> = [];
items.forEach((item) => {
menuItemsControls.push(this.fb.control(deepClone(item)));
});
return this.fb.array(menuItemsControls);
}
private computeMaxIconNameBlockWidth() {
if (this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm'])) {
this.maxIconNameBlockWidth = 256;
} else if (this.breakpointObserver.isMatched(MediaBreakpoints['gt-xs'])) {
this.maxIconNameBlockWidth = 200;
} else {
this.maxIconNameBlockWidth = 0;
}
}
private pagesFormArray(): FormArray {
return this.pagesForm.get('pages') as FormArray;
}
private actualMobilePageIndex(index: number): number {
const menuItem = this.visibleMobilePagesControls()[index];
return this.pagesFormArray().controls.indexOf(menuItem);
}
}

75
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html

@ -0,0 +1,75 @@
<!--
Copyright © 2016-2024 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 class="tb-mobile-page-item-row-component-container">
<div class="tb-mobile-page-item-row-component">
<div class="tb-form-row" [formGroup]="mobilePageRowForm">
<div class="tb-mobile-page-item-start-block">
<ng-content select=".tb-custom-menu-item-row-prefix"></ng-content>
<div class="tb-mobile-page-item-icon-name-block" [style.width]="iconNameBlockWidth">
<mat-slide-toggle matTooltip="{{ (mobilePageRowForm.get('visible').value ? 'mobile.visible' : 'mobile.hidden') | translate }}"
matTooltipPosition="above"
formControlName="visible"></mat-slide-toggle>
<tb-material-icon-select asBoxInput
color="#305680"
formControlName="icon">
</tb-material-icon-select>
<div tbTruncateWithTooltip> {{ itemName }} </div>
</div>
</div>
<div fxHide.xs class="tb-mobile-page-item-input-block">
<mat-form-field class="flex tb-suffix-absolute" appearance="outline" subscriptSizing="dynamic">
<input matInput [required]="isCustomMenuItem" formControlName="label" placeholder="{{ itemNamePlaceholder }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
matTooltip="{{ 'mobile.page-name-required' | translate }}"
*ngIf="mobilePageRowForm.get('label').hasError('required') && mobilePageRowForm.get('label').touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
<mat-form-field *ngIf="isCustomMenuItem"
class="flex tb-mobile-page-item-info" appearance="outline" subscriptSizing="dynamic">
<input matInput readonly [placeholder]="itemInfo">
</mat-form-field>
</div>
</div>
<button *ngIf="!disabled"
type="button"
mat-icon-button
#editButton
(click)="edit($event, editButton)"
matTooltip="{{ 'mobile.edit-page' | translate }}"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button *ngIf="!disabled && isDefaultMenuItem"
[disabled]="!isCleanupEnabled"
matTooltip="{{ 'mobile.reset-to-page-default' | translate }}"
matTooltipPosition="above"
(click)="cleanup()">
<tb-icon>mdi:broom</tb-icon>
</button>
<button mat-icon-button *ngIf="!disabled && isCustomMenuItem"
matTooltip="{{ 'mobile.delete-page' | translate }}"
matTooltipPosition="above"
(click)="delete()">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>

84
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.scss

@ -0,0 +1,84 @@
/**
* Copyright © 2016-2024 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 "../scss/constants";
@import '../scss/mixins';
.tb-mobile-page-item-row-component-container {
display: flex;
flex-direction: column;
gap: 0;
}
.tb-mobile-page-item-row-component {
display: flex;
gap: 8px;
align-items: center;
.mat-mdc-icon-button.mat-mdc-button-base {
&:not(.tb-drag-handle) {
@media #{$mat-xs} {
@include tb-mat-icon-button-size(24);
}
@media #{$mat-sm} {
@include tb-mat-icon-button-size(32);
}
}
&:not(:disabled) {
.mat-icon {
color: rgba(0, 0, 0, 0.54);
}
}
&:disabled {
.mat-icon {
color: rgba(0, 0, 0, 0.12);
}
}
}
.tb-form-row {
flex: 1;
min-width: 0;
.tb-mobile-page-item-start-block {
display: flex;
align-items: center;
gap: 0;
min-width: 0;
}
.tb-mobile-page-item-icon-name-block {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.tb-mobile-page-item-input-block {
display: flex;
gap: 8px;
flex: 1;
}
}
}
.tb-mobile-page-item-info {
.mdc-text-field:not(.mdc-text-field--disabled) {
input.mdc-text-field__input {
cursor: default;
&::placeholder {
color: $tb-primary-color;
}
&:-ms-input-placeholder {
color: $tb-primary-color;
}
}
}
}

323
ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts

@ -0,0 +1,323 @@
///
/// Copyright © 2016-2024 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 {
booleanAttribute,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
forwardRef,
inject,
Input,
numberAttribute,
OnInit,
Output,
Renderer2,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { Observable } from 'rxjs';
import {
CustomMobilePage,
DefaultMobilePage,
defaultMobilePageMap,
hideDefaultMenuItems,
MobilePageType,
mobilePageTypeTranslations
} from '@shared/models/mobile-app.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { deepClone } from '@core/utils';
import { TbPopoverService } from '@shared/components/popover.service';
import { CustomMobilePagePanelComponent } from '@home/pages/mobile/bundes/layout/custom-mobile-page-panel.component';
import { DefaultMobilePagePanelComponent } from '@home/pages/mobile/bundes/layout/default-mobile-page-panel.component';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'tb-mobile-menu-item-row',
templateUrl: './mobile-page-item-row.component.html',
styleUrls: ['./mobile-page-item-row.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MobilePageItemRowComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => MobilePageItemRowComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class MobilePageItemRowComponent implements ControlValueAccessor, OnInit, Validator {
@Input({transform: booleanAttribute})
disabled: boolean;
@Input({transform: numberAttribute})
maxIconNameBlockWidth = 256;
@Input()
hideItems: Observable<void>;
@Output()
pageRemoved = new EventEmitter();
iconNameBlockWidth = '256px';
itemInfo: string;
isDefaultMenuItem = false;
isCustomMenuItem = false;
isCleanupEnabled = false;
mobilePageRowForm = this.fb.group({
visible: [true, []],
icon: ['', []],
label: ['', []],
type: [MobilePageType.DEFAULT]
});
private propagateChange = (_val: any) => {};
private destroyRef = inject(DestroyRef);
private defaultMobilePages: Omit<DefaultMobilePage, 'type' | 'visible'>;
private defaultItemName: string;
private modelValue: DefaultMobilePage | CustomMobilePage;
constructor(private fb: FormBuilder,
private cd: ChangeDetectorRef,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private translate: TranslateService) {
this.mobilePageRowForm.valueChanges.pipe(
takeUntilDestroyed()
).subscribe(
() => this.updateModel()
);
}
ngOnInit() {
this.updateIconNameBlockWidth();
if (this.hideItems) {
this.hideItems.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
if (!this.disabled && this.modelValue.visible) {
this.mobilePageRowForm.patchValue(
{visible: false}, {emitEvent: true}
);
}
});
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled;
if (isDisabled) {
this.mobilePageRowForm.disable({emitEvent: false});
} else {
this.mobilePageRowForm.enable({emitEvent: false});
}
}
validate(): ValidationErrors | null {
if (!this.mobilePageRowForm.valid) {
return {
invalidMobileMenuItem: true
};
}
return null;
}
writeValue(value: DefaultMobilePage | CustomMobilePage) {
this.modelValue = value;
this.mobilePageRowForm.patchValue(
{
visible: value.visible,
icon: value.icon,
label: value.label
}, {emitEvent: false}
);
if (this.modelValue.type === MobilePageType.DEFAULT) {
this.isDefaultMenuItem = true;
const defaultPage = this.modelValue as DefaultMobilePage;
if (defaultMobilePageMap.has(defaultPage.id)) {
this.defaultMobilePages = defaultMobilePageMap.get(defaultPage.id);
this.defaultItemName = this.defaultMobilePages.label;
if (!value.icon) {
this.mobilePageRowForm.patchValue({
icon: this.defaultMobilePages.icon
}, {emitEvent: false});
}
}
} else {
this.isCustomMenuItem = true;
this.mobilePageRowForm.get('label').setValidators([Validators.required]);
this.mobilePageRowForm.get('label').updateValueAndValidity({emitEvent: false});
}
this.updateCleanupState();
this.updateItemInfo();
this.cd.markForCheck();
}
cleanup() {
const visible = !hideDefaultMenuItems.includes((this.modelValue as DefaultMobilePage).id)
this.mobilePageRowForm.patchValue(
{
visible: visible,
icon: this.defaultMobilePages.icon,
label: null
}, {emitEvent: false}
);
this.modelValue.visible = visible;
delete this.modelValue.icon;
delete this.modelValue.label;
this.updateModel();
}
delete() {
this.pageRemoved.emit();
}
edit($event: Event, matButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
disabled: this.disabled,
pageItem: deepClone(this.modelValue)
};
if (this.isDefaultMenuItem) {
const defaultMobilePagePanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, DefaultMobilePagePanelComponent, ['right', 'bottom', 'top'], true, null,
ctx,
{},
{}, {}, false, () => {}, {padding: '16px 24px'});
defaultMobilePagePanelPopover.tbComponentRef.instance.popover = defaultMobilePagePanelPopover;
defaultMobilePagePanelPopover.tbComponentRef.instance.defaultMobilePageApplied.subscribe((menuItem) => {
defaultMobilePagePanelPopover.hide();
this.afterPageEdit(menuItem);
});
} else {
const customMobilePagePanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, CustomMobilePagePanelComponent, ['right', 'bottom', 'top'], true, null,
ctx,
{},
{}, {}, false, () => {}, {padding: '16px 24px'});
customMobilePagePanelPopover.tbComponentRef.instance.popover = customMobilePagePanelPopover;
customMobilePagePanelPopover.tbComponentRef.instance.customMobilePageApplied.subscribe((page) => {
customMobilePagePanelPopover.hide();
this.afterPageEdit(page);
});
}
}
}
get itemName(): string {
return this.isDefaultMenuItem ? this.defaultItemName : this.mobilePageRowForm.get('label').value;
}
get itemNamePlaceholder(): string {
return this.isDefaultMenuItem ? this.defaultItemName : '';
}
private updateIconNameBlockWidth() {
if (this.maxIconNameBlockWidth) {
this.iconNameBlockWidth = `${this.maxIconNameBlockWidth}px`;
} else {
this.iconNameBlockWidth = '100%';
}
}
private updateModel() {
this.modelValue.visible = this.mobilePageRowForm.get('visible').value;
const label = this.mobilePageRowForm.get('label').value;
if (label) {
this.modelValue.label = label;
} else {
delete this.modelValue.label;
}
let icon = this.mobilePageRowForm.get('icon').value;
if (this.isDefaultMenuItem) {
if (this.defaultMobilePages.icon === icon) {
icon = null;
}
}
if (icon) {
this.modelValue.icon = icon;
} else {
delete this.modelValue.icon;
}
this.updateCleanupState();
this.updateItemInfo();
this.propagateChange(this.modelValue);
}
private updateCleanupState() {
if (this.isDefaultMenuItem) {
this.isCleanupEnabled =
(hideDefaultMenuItems.includes((this.modelValue as DefaultMobilePage).id) ? this.modelValue.visible : !this.modelValue.visible) ||
!!this.modelValue.label ||
!!this.modelValue.icon;
}
}
private updateItemInfo() {
if (this.isCustomMenuItem) {
this.itemInfo = this.translate.instant(mobilePageTypeTranslations.get(this.modelValue.type));
} else {
this.itemInfo = '';
}
}
private afterPageEdit(page: DefaultMobilePage | CustomMobilePage) {
this.mobilePageRowForm.patchValue({
visible: page.visible,
icon: page.icon || (this.isDefaultMenuItem ? this.defaultMobilePages.icon : null),
label: page.label
}, {emitEvent: false});
if (this.isCustomMenuItem) {
this.modelValue = page;
}
this.updateModel();
}
}

109
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html

@ -0,0 +1,109 @@
<!--
Copyright © 2016-2024 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.
-->
<mat-toolbar color="primary">
<h2>{{ dialogTitle | translate }}</h2>
<span fxFlex></span>
<div tb-help="ruleNotifications"></div>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content ngStyle.xs="padding: 0;">
<mat-horizontal-stepper [linear]="true" labelPosition="end" #addMobileBundle [orientation]="(stepperOrientation | async)"
(selectionChange)="changeStep($event)">
<ng-template matStepperIcon="edit">
<mat-icon>check</mat-icon>
</ng-template>
<mat-step [stepControl]="bundlesForms">
<ng-template matStepLabel>{{ 'mobile.basic-settings' | translate }}</ng-template>
<form [formGroup]="bundlesForms" style="margin-top: 4px;">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label translate>mobile.title</mat-label>
<input matInput required formControlName="title">
<mat-hint> </mat-hint>
<mat-error *ngIf="bundlesForms.get('title').hasError('required')">
{{ 'mobile.title-required' | translate }}
</mat-error>
</mat-form-field>
<tb-entity-autocomplete
allowCreateNew
useFullEntityId
appearance="outline"
labelText="{{ 'mobile.android-application' | translate }}"
[entityType]="entityType.MOBILE_APP"
[entitySubtype]="platformType.ANDROID"
(createNew)="createApplication('androidAppId', platformType.ANDROID)"
formControlName="androidAppId">
</tb-entity-autocomplete>
<tb-entity-autocomplete
allowCreateNew
useFullEntityId
appearance="outline"
labelText="{{ 'mobile.ios-application' | translate }}"
[entityType]="entityType.MOBILE_APP"
[entitySubtype]="platformType.IOS"
(createNew)="createApplication('iosAppId', platformType.IOS)"
formControlName="iosAppId">
</tb-entity-autocomplete>
<mat-form-field appearance="outline">
<mat-label translate>mobile.description</mat-label>
<textarea matInput formControlName="description" rows="1"></textarea>
</mat-form-field>
</form>
</mat-step>
<mat-step [stepControl]="oauthForms">
<ng-template matStepLabel>{{ 'admin.oauth2.oauth2' | translate }}</ng-template>
<form [formGroup]="oauthForms" style="margin-top: 8px;">
<mat-slide-toggle formControlName="oauth2Enabled">
{{ 'mobile.enable-oauth' | translate }}
</mat-slide-toggle>
<tb-entity-list formControlName="oauth2ClientIds"
[entityType]="entityType.OAUTH2_CLIENT"
appearance="outline"
labelText="{{ 'admin.oauth2.clients' | translate }}"
placeholderText="{{ 'admin.oauth2.add-client' | translate }}">
<button mat-button color="primary" matSuffix
[disabled]="this.oauthForms.get('oauth2ClientIds').disabled"
(click)="createClient($event)">
<span style="white-space: nowrap">{{ 'admin.oauth2.create-new' | translate }}</span>
</button>
</tb-entity-list>
</form>
</mat-step>
<mat-step [stepControl]="layoutForms">
<ng-template matStepLabel>{{ 'mobile.layout' | translate }}</ng-template>
<form [formGroup]="layoutForms" class="layout-form">
<tb-mobile-layout formControlName="layoutConfig">
</tb-mobile-layout>
</form>
</mat-step>
</mat-horizontal-stepper>
</div>
<div mat-dialog-actions fxLayout="row">
<button mat-stroked-button *ngIf="selectedIndex > 0"
(click)="backStep()">{{ 'action.back' | translate }}</button>
<span fxFlex></span>
<button mat-raised-button
color="primary"
(click)="nextStep()">{{ nextStepLabel() | translate }}</button>
</div>

73
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.scss

@ -0,0 +1,73 @@
/**
* Copyright © 2016-2024 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 "../../../../../../scss/constants";
:host {
width: 850px;
height: 100%;
max-width: 100%;
max-height: 100vh;
display: grid;
grid-template-rows: min-content 4px minmax(auto, 1fr) min-content;
.mat-mdc-slide-toggle {
margin-bottom: 16px;
}
form.layout-form {
display: block;
height: 100%;
max-height: 100%;
}
::ng-deep {
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 !important;
color: rgba(0, 0, 0, 0.87);
.mat-stepper-horizontal {
display: flex;
height: 100%;
overflow: hidden;
.mat-horizontal-stepper-wrapper {
flex: 1 1 100%;
max-width: 100%;
}
.mat-horizontal-content-container {
height: 700px;
max-height: 100%;
width: 100%;;
overflow-y: auto;
scrollbar-gutter: stable;
@media #{$mat-gt-sm} {
min-width: 500px;
}
.mat-horizontal-stepper-content:not(.mat-horizontal-stepper-content-inactive):nth-child(3) {
height: 100%;
max-height: 100%;
overflow: hidden;
}
}
}
}
}
}

226
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.ts

@ -0,0 +1,226 @@
///
/// Copyright © 2016-2024 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 { Component, Inject, ViewChild } from '@angular/core';
import { MobileApp, MobileAppBundle, MobileAppBundleInfo } from '@shared/models/mobile-app.models';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MediaBreakpoints } from '@shared/models/constants';
import { map } from 'rxjs/operators';
import { forkJoin, Observable } from 'rxjs';
import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper';
import { BreakpointObserver } from '@angular/cdk/layout';
import { FormBuilder, Validators } from '@angular/forms';
import { EntityType } from '@shared/models/entity-type.models';
import { PlatformType } from '@shared/models/oauth2.models';
import { MatStepper } from '@angular/material/stepper';
import {
MobileAppDialogComponent,
MobileAppDialogData
} from '@home/pages/mobile/applications/mobile-app-dialog.component';
import { ClientDialogComponent } from '@home/pages/admin/oauth2/clients/client-dialog.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MobileAppService } from '@core/http/mobile-app.service';
import { deepClone, deepTrim } from '@core/utils';
export interface MobileBundleDialogData {
bundle?: MobileAppBundleInfo;
isAdd?: boolean;
}
@Component({
selector: 'tb-mobile-bundle-dialog',
templateUrl: './mobile-bundle-dialog.component.html',
styleUrls: ['./mobile-bundle-dialog.component.scss']
})
export class MobileBundleDialogComponent extends DialogComponent<MobileBundleDialogComponent, MobileAppBundle> {
@ViewChild('addMobileBundle', {static: true}) addMobileBundle: MatStepper;
readonly entityType = EntityType;
selectedIndex = 0;
dialogTitle = 'mobile.edit-bundle';
stepperOrientation: Observable<StepperOrientation>;
platformType = PlatformType;
bundlesForms = this.fb.group({
title: ['', Validators.required],
androidAppId: [null],
iosAppId: [null],
description: [''],
});
oauthForms = this.fb.group({
oauth2Enabled: [true],
oauth2ClientIds: [null]
});
layoutForms = this.fb.group({
layoutConfig: [null]
});
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<MobileBundleDialogComponent, MobileAppBundle>,
@Inject(MAT_DIALOG_DATA) public data: MobileBundleDialogData,
private breakpointObserver: BreakpointObserver,
private fb: FormBuilder,
private dialog: MatDialog,
private mobileAppService: MobileAppService) {
super(store, router, dialogRef);
if (this.data.isAdd) {
this.dialogTitle = 'mobile.add-bundle'
}
this.stepperOrientation = this.breakpointObserver.observe(MediaBreakpoints['gt-xs'])
.pipe(map(({matches}) => matches ? 'horizontal' : 'vertical'));
this.oauthForms.get('oauth2Enabled').valueChanges.pipe(
takeUntilDestroyed()
).subscribe(value => {
if (value) {
this.oauthForms.get('oauth2ClientIds').enable({emitEvent: false});
} else {
this.oauthForms.get('oauth2ClientIds').disable({emitEvent: false});
}
})
if (!this.data.isAdd && this.data.bundle) {
this.bundlesForms.patchValue(this.data.bundle, {emitEvent: false});
this.oauthForms.get('oauth2Enabled').setValue(this.data.bundle.oauth2Enabled, {onlySelf: true});
this.oauthForms.get('oauth2ClientIds')
.setValue(deepClone(this.data.bundle.oauth2ClientInfos.map(item => item.id.id)), {emitEvent: false});
this.layoutForms.patchValue(this.data.bundle, {emitEvent: false});
}
}
cancel(): void {
this.dialogRef.close(null);
}
backStep() {
this.addMobileBundle.previous();
}
nextStep() {
if (this.selectedIndex >= this.maxStepperIndex) {
this.add();
} else {
this.addMobileBundle.next();
}
}
nextStepLabel(): string {
if (this.selectedIndex >= this.maxStepperIndex) {
return this.data.isAdd ? 'action.add' : 'action.save';
}
return 'action.next';
}
changeStep($event: StepperSelectionEvent) {
this.selectedIndex = $event.selectedIndex;
if ($event.previouslySelectedIndex > $event.selectedIndex) {
$event.previouslySelectedStep.interacted = false;
}
}
createApplication(formControl: string, platformType: PlatformType) {
this.dialog.open<MobileAppDialogComponent, MobileAppDialogData, MobileApp>(MobileAppDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
platformType
}
}).afterClosed()
.subscribe((app) => {
if (app) {
this.bundlesForms.get(formControl).patchValue(app.id);
this.bundlesForms.get(formControl).markAsDirty();
}
});
}
createClient($event: Event) {
if ($event) {
$event.stopPropagation();
$event.preventDefault();
}
this.dialog.open<ClientDialogComponent>(ClientDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {}
}).afterClosed()
.subscribe((client) => {
if (client) {
const formValue = this.oauthForms.get('oauth2ClientIds').value ?
[...this.oauthForms.get('oauth2ClientIds').value] : [];
formValue.push(client.id.id);
this.oauthForms.get('oauth2ClientIds').patchValue(formValue);
this.oauthForms.get('oauth2ClientIds').markAsDirty();
}
});
}
private get maxStepperIndex(): number {
return this.addMobileBundle?._steps?.length - 1;
}
private add(): void {
if (this.allValid()) {
let task = {
mobileBundle: this.mobileAppService.saveMobileAppBundle(this.mobileAppBundleFormValue, this.oauthForms.value.oauth2ClientIds),
oath2Clients: null
}
if (this.data.isAdd) {
delete task.oath2Clients;
} else {
const mobileBundle: MobileAppBundle = {...this.data.bundle, ...this.mobileAppBundleFormValue};
task.mobileBundle = this.mobileAppService.saveMobileAppBundle(mobileBundle);
task.oath2Clients = this.mobileAppService.updateOauth2Clients(mobileBundle.id.id, this.oauthForms.get('oauth2ClientIds').value);
}
forkJoin(task).subscribe(
(res) => this.dialogRef.close(res.mobileBundle)
);
}
}
private allValid(): boolean {
return !this.addMobileBundle.steps.find((item, index) => {
if (item.stepControl?.invalid) {
item.interacted = true;
this.addMobileBundle.selectedIndex = index;
return true;
} else {
return false;
}
});
}
private get mobileAppBundleFormValue(): MobileAppBundle {
const formValue = deepTrim(this.bundlesForms.value) as MobileAppBundle;
formValue.layoutConfig = deepTrim(this.layoutForms.value.layoutConfig);
formValue.oauth2Enabled = this.oauthForms.value.oauth2Enabled;
return formValue;
}
}

142
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts

@ -0,0 +1,142 @@
///
/// Copyright © 2016-2024 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 {
CellActionDescriptor,
checkBoxCell,
DateEntityTableColumn,
EntityChipsEntityTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { MobileAppBundleInfo } from '@shared/models/mobile-app.models';
import { ActivatedRouteSnapshot } from '@angular/router';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { Direction } from '@shared/models/page/sort-order';
import { MobileBundleTableHeaderComponent } from '@home/pages/mobile/bundes/mobile-bundle-table-header.component';
import { DatePipe } from '@angular/common';
import { MobileAppService } from '@core/http/mobile-app.service';
import { map } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { EntityAction } from '@home/models/entity/entity-component.models';
import { MatDialog } from '@angular/material/dialog';
import {
MobileBundleDialogComponent,
MobileBundleDialogData
} from '@home/pages/mobile/bundes/mobile-bundle-dialog.component';
import { NotificationTemplate } from '@shared/models/notification.models';
@Injectable()
export class MobileBundleTableConfigResolver {
private readonly config: EntityTableConfig<MobileAppBundleInfo> = new EntityTableConfig<MobileAppBundleInfo>();
constructor(
private datePipe: DatePipe,
private mobileAppService: MobileAppService,
private translate : TranslateService,
private dialog: MatDialog,
) {
this.config.selectionEnabled = false;
this.config.entityType = EntityType.MOBILE_APP_BUNDLE;
this.config.addEnabled = false;
this.config.rowPointer = true;
this.config.detailsPanelEnabled = false;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP_BUNDLE);
this.config.entityResources = entityTypeResources.get(EntityType.MOBILE_APP_BUNDLE);
this.config.headerComponent = MobileBundleTableHeaderComponent;
this.config.onEntityAction = action => this.onBundleAction(action);
this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'};
this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.config.columns.push(
new DateEntityTableColumn<MobileAppBundleInfo>('createdTime', 'common.created-time', this.datePipe, '170px'),
new EntityTableColumn<MobileAppBundleInfo>('title', 'mobile.title', '25%'),
new EntityChipsEntityTableColumn<MobileAppBundleInfo>('oauth2ClientInfos', 'mobile.oauth-clients', '35%'),
new EntityChipsEntityTableColumn<MobileAppBundleInfo>('androidPkg', 'mobile.android-app', '20%'),
new EntityChipsEntityTableColumn<MobileAppBundleInfo>('iosPkg', 'mobile.ios-app', '20%'),
new EntityTableColumn<MobileAppBundleInfo>('oauth2Enabled', 'mobile.enable-oauth', '140px',
entity => checkBoxCell(entity.oauth2Enabled))
)
this.config.deleteEnabled = bundle => !(bundle.iosAppId || bundle.androidAppId);
this.config.deleteEntityTitle = (bundle) => this.translate.instant('mobile.delete-applications-bundle-title', {bundleName: bundle.name});
this.config.deleteEntityContent = () => this.translate.instant('mobile.delete-applications-bundle-text');
this.config.deleteEntity = id => this.mobileAppService.deleteMobileAppBundle(id.id);
this.config.entitiesFetchFunction = pageLink => this.mobileAppService.getTenantMobileAppBundleInfos(pageLink).pipe(
map(bundles => {
bundles.data.map(data => {
if (data.androidPkgName) {
data.androidPkg = {
id: data.androidAppId,
name: data.androidPkgName
}
}
if (data.iosPkgName) {
data.iosPkg = {
id: data.iosAppId,
name: data.iosPkgName
}
}
})
return bundles;
})
);
this.config.handleRowClick = ($event, bundle) => {
$event?.stopPropagation();
this.mobileAppService.getMobileAppBundleInfoById(bundle.id.id).subscribe(appBundleInfo => {
this.editBundle($event, appBundleInfo);
})
return true;
};
}
resolve(_route: ActivatedRouteSnapshot): EntityTableConfig<MobileAppBundleInfo> {
return this.config;
}
private editBundle($event: Event, bundle: MobileAppBundleInfo, isAdd = false) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<MobileBundleDialogComponent, MobileBundleDialogData,
MobileAppBundleInfo>(MobileBundleDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
isAdd,
bundle
}
}).afterClosed()
.subscribe((res) => {
if (res) {
this.config.updateData();
}
});
}
private onBundleAction(action: EntityAction<MobileAppBundleInfo>): boolean {
switch (action.action) {
case 'add':
this.editBundle(action.event, action.entity, true);
return true;
}
return false;
}
}

28
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.html

@ -0,0 +1,28 @@
<!--
Copyright © 2016-2024 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="100" fxLayout="row" fxLayoutAlign="space-between center" fxLayoutAlign.xs="start center">
<section fxHide fxShow.gt-xs class="tb-entity-title-container">
<div tbTruncateWithTooltip translate>mobile.bundles</div>
<div tb-help="oauth2Settings"></div>
</section>
<button mat-stroked-button color="primary" (click)="createBundle($event)">
<div fxLayout="row" fxLayoutAlign="start center">
<mat-icon>add</mat-icon>{{ 'mobile.add-bundle' | translate }}
</div>
</button>
</div>

23
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.scss

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2024 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{
width: 100000px;
.tb-entity-title-container {
display: flex;
align-items: center;
}
}

37
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.ts

@ -0,0 +1,37 @@
///
/// Copyright © 2016-2024 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 { Component } from '@angular/core';
import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component';
import { MobileAppBundleInfo } from '@shared/models/mobile-app.models';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@Component({
selector: 'tb-mobile-bundle-table-header',
templateUrl: './mobile-bundle-table-header.component.html',
styleUrls: ['./mobile-bundle-table-header.component.scss']
})
export class MobileBundleTableHeaderComponent extends EntityTableHeaderComponent<MobileAppBundleInfo> {
constructor(protected store: Store<AppState>) {
super(store);
}
createBundle($event: Event) {
this.entitiesTableConfig.onEntityAction({event: $event, action: 'add', entity: null});
}
}

2
ui-ngx/src/app/modules/home/pages/mobile/mobile-routing.module.ts

@ -23,6 +23,7 @@ import { MobileAppTableConfigResolver } from '@home/pages/mobile/applications/mo
import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component';
import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
import { applicationsRoutes } from '@home/pages/mobile/applications/applications-routing.module';
import { bundlesRoutes } from '@home/pages/mobile/bundes/bundles-routing.module';
const routes: Routes = [
{
@ -44,6 +45,7 @@ const routes: Routes = [
}
},
...applicationsRoutes,
...bundlesRoutes,
{
path: 'mobile-app',
component: MobileAppSettingsComponent,

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

@ -19,14 +19,16 @@ import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import { HomeComponentsModule } from '@home/components/home-components.module';
import { MobileRoutingModule } from '@home/pages/mobile/mobile-routing.module';
import { ApplicationModule } from '@home/pages/mobile/applications/applications.module';
import { MobileApplicationModule } from '@home/pages/mobile/applications/applications.module';
import { MobileBundlesModule } from '@home/pages/mobile/bundes/bundles.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
HomeComponentsModule,
ApplicationModule,
MobileApplicationModule,
MobileBundlesModule,
MobileRoutingModule,
]
})

6
ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html

@ -33,6 +33,12 @@
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<button mat-button color="primary" matSuffix
type="button"
*ngIf="allowCreateNew && !selectEntityFormGroup.get('entity').value && !disabled"
(click)="createNewEntity($event)">
<span style="white-space: nowrap">{{ 'entity.create-new' | translate }}</span>
</button>
<mat-autocomplete class="tb-autocomplete"
#entityAutocomplete="matAutocomplete"
[displayWith]="displayEntityFn">

18
ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts

@ -131,9 +131,16 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit
@coerceBoolean()
disabled: boolean;
@Input()
@coerceBoolean()
allowCreateNew: boolean;
@Output()
entityChanged = new EventEmitter<BaseData<EntityId>>();
@Output()
createNew = new EventEmitter<void>();
@ViewChild('entityInput', {static: true}) entityInput: ElementRef;
get requiredErrorText(): string {
@ -266,6 +273,12 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit
this.entityRequiredText = 'queue-statistics.queue-statistics-required';
this.notFoundEntities = 'queue-statistics.no-queue-statistics-text';
break;
case EntityType.MOBILE_APP:
this.entityText = 'mobile.applications';
this.noEntitiesMatchingText = 'mobile.no-application-matching';
this.entityRequiredText = 'mobile.application-required';
this.notFoundEntities = 'mobile.no-application-text';
break;
case AliasEntityType.CURRENT_CUSTOMER:
this.entityText = 'customer.default-customer';
this.noEntitiesMatchingText = 'customer.no-customers-matching';
@ -417,4 +430,9 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit
}
return entityType;
}
createNewEntity($event: Event) {
$event.stopPropagation();
this.createNew.emit();
}
}

2
ui-ngx/src/app/shared/components/entity/entity-list.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<mat-form-field [formGroup]="entityListFormGroup" class="mat-block" [ngClass]="{'tb-chip-list': !labelText}" [subscriptSizing]="subscriptSizing">
<mat-form-field [formGroup]="entityListFormGroup" class="mat-block" [appearance]="appearance" [ngClass]="{'tb-chip-list': !labelText}" [subscriptSizing]="subscriptSizing">
<mat-label *ngIf="labelText">{{ labelText }}</mat-label>
<mat-chip-grid #chipList formControlName="entities">
<mat-chip-row

5
ui-ngx/src/app/shared/components/entity/entity-list.component.ts

@ -44,7 +44,7 @@ import { EntityService } from '@core/http/entity.service';
import { MatAutocomplete } from '@angular/material/autocomplete';
import { MatChipGrid } from '@angular/material/chips';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { SubscriptSizing } from '@angular/material/form-field';
import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
import { coerceBoolean } from '@shared/decorators/coercion';
import { isArray } from 'lodash';
@ -86,6 +86,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV
@Input()
requiredText = this.translate.instant('entity.entity-list-empty');
@Input()
appearance: MatFormFieldAppearance = 'fill';
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;

21
ui-ngx/src/app/shared/models/entity-type.models.ts

@ -460,9 +460,20 @@ export const entityTypeTranslations = new Map<EntityType | AliasEntityType, Enti
list: 'entity.list-of-mobile-apps',
details: 'admin.oauth2.mobile-app-details',
add: 'mobile.add-application',
noEntities: 'mobile.no=application',
noEntities: 'mobile.no-application',
search: 'mobile.search-application'
}
],
[
EntityType.MOBILE_APP_BUNDLE,
{
type: 'entity.type-mobile-app-bundle',
typePlural: 'entity.type-mobile-app-bundles',
list: 'entity.list-of-mobile-app-bundles',
add: 'mobile.add-bundle',
noEntities: 'mobile.no-bundles',
search: 'mobile.search-bundles'
}
]
]
);
@ -588,6 +599,12 @@ export const entityTypeResources = new Map<EntityType, EntityTypeResource<BaseDa
{
helpLinkId: 'oauth2Settings'
}
],
[
EntityType.MOBILE_APP_BUNDLE,
{
helpLinkId: 'mobileBundles'
}
]
]
);
@ -612,7 +629,7 @@ export const baseDetailsPageByEntityType = new Map<EntityType, string>([
[EntityType.WIDGET_TYPE, '/resources/widgets-library/widget-types/details'],
[EntityType.OAUTH2_CLIENT, '/security-settings/oauth2/clients/details'],
[EntityType.DOMAIN, '/security-settings/oauth2/clients/details'],
[EntityType.MOBILE_APP, '/security-settings/oauth2/clients/details']
[EntityType.MOBILE_APP, '/mobile-center/applications']
]);
export interface EntitySubtype {

189
ui-ngx/src/app/shared/models/mobile-app.models.ts

@ -19,6 +19,7 @@ import { BaseData } from '@shared/models/base-data';
import { MobileAppId } from '@shared/models/id/mobile-app-id';
import { OAuth2ClientInfo, PlatformType } from '@shared/models/oauth2.models';
import { MobileAppBundleId } from '@shared/models/id/mobile-app-bundle-id';
import { deepClone, isNotEmptyStr } from '@core/utils';
export interface QrCodeSettings extends HasTenantId {
useDefaultApp: boolean;
@ -119,20 +120,43 @@ enum MobileMenuPath {
DASHBOARD = 'DASHBOARD',
AUDIT_LOGS = 'AUDIT_LOGS',
CUSTOMERS = 'CUSTOMERS',
CUSTOMER = 'CUSTOMER',
NOTIFICATION = 'NOTIFICATION',
CUSTOM = 'CUSTOM'
NOTIFICATIONS = 'NOTIFICATIONS'
}
export interface MobileMenuItem {
label: string;
icon: string;
path: MobileMenuPath;
id: string;
export enum MobilePageType {
DEFAULT = 'DEFAULT',
CUSTOM = 'CUSTOM',
DASHBOARD = 'DASHBOARD',
WEB_VIEW = 'WEB_VIEW',
}
export const mobilePageTypeTranslations = new Map<MobilePageType, string>(
[
[MobilePageType.CUSTOM, 'mobile.pages-types.custom'],
[MobilePageType.DASHBOARD, 'mobile.pages-types.dashboard'],
[MobilePageType.WEB_VIEW, 'mobile.pages-types.web-view'],
]
);
export interface MobilePage {
label?: string;
icon?: string;
type: MobilePageType;
visible: boolean;
}
export interface DefaultMobilePage extends MobilePage {
id: MobileMenuPath;
}
export interface CustomMobilePage extends MobilePage {
dashboardId?: string;
url?: string;
path?: string;
}
export interface MobileLayoutConfig {
items: MobileMenuItem[];
pages: MobilePage[];
}
export interface MobileAppBundle extends Omit<BaseData<MobileAppBundleId>, 'label'>, HasTenantId {
@ -147,5 +171,152 @@ export interface MobileAppBundle extends Omit<BaseData<MobileAppBundleId>, 'labe
export interface MobileAppBundleInfo extends MobileAppBundle {
androidPkgName: string;
iosPkgName: string;
androidPkg?: {
name: string;
id: MobileAppId
};
iosPkg?: {
name: string;
id: MobileAppId
}
oauth2ClientInfos?: Array<OAuth2ClientInfo>;
qrCodeEnabled: boolean;
}
const defaultMobileMenu = [
MobileMenuPath.HOME,
MobileMenuPath.ALARMS,
MobileMenuPath.DEVICES,
MobileMenuPath.CUSTOMERS,
MobileMenuPath.ASSETS,
MobileMenuPath.AUDIT_LOGS,
MobileMenuPath.NOTIFICATIONS,
MobileMenuPath.DEVICE_LIST,
MobileMenuPath.DASHBOARDS
];
export const hideDefaultMenuItems = [
MobileMenuPath.DEVICE_LIST,
MobileMenuPath.DASHBOARDS
];
export const getDefaultMobileMenuItem = (): DefaultMobilePage[] => {
return deepClone(defaultMobileMenu).map(item => ({
visible: !hideDefaultMenuItems.includes(item),
type: MobilePageType.DEFAULT,
id: item
}))
}
export const isDefaultMobileMenuItem = (item: MobilePage): item is DefaultMobilePage => {
const path = (item as DefaultMobilePage).id;
return isNotEmptyStr(path) && defaultMobilePageMap.has(path);
};
const mobilePageEqualToDefault = (item: MobilePage, defaultMobilePage: DefaultMobilePage): boolean => {
if (isDefaultMobileMenuItem(item) && (hideDefaultMenuItems.includes(item.id) ? !item.visible : item.visible)) {
return !(item.id !== defaultMobilePage.id || !!item.label || !!item.icon);
} else {
return false;
}
};
export const isDefaultMobilePagesConfig = (items: MobilePage[]): boolean => {
const defaultMenus = getDefaultMobileMenuItem();
if (!items?.length && !defaultMenus?.length) {
return true;
} else if (items.length !== defaultMenus.length) {
return false;
} else {
for (let i = 0; i < items.length; i++) {
const item = items[i];
const defaultMenuItem = defaultMenus[i];
if (!mobilePageEqualToDefault(item, defaultMenuItem)) {
return false;
}
}
return true;
}
};
export const mobileMenuDividers = new Map<number, string>([
[2, 'mobile.mobile-599'],
[4, 'mobile.tablet-959'],
[6, 'mobile.max-element-number'],
]);
export const defaultMobilePageMap = new Map<MobileMenuPath, Omit<DefaultMobilePage, 'type' | 'visible'>>([
[
MobileMenuPath.HOME,
{
id: MobileMenuPath.HOME,
icon: 'home',
label: 'Home'
}
],
[
MobileMenuPath.ALARMS,
{
id: MobileMenuPath.ALARMS,
icon: 'notifications',
label: 'Alarms'
}
],
[
MobileMenuPath.DEVICES,
{
id: MobileMenuPath.DEVICES,
icon: 'devices_other',
label: 'Devices'
}
],
[
MobileMenuPath.CUSTOMERS,
{
id: MobileMenuPath.CUSTOMERS,
icon: 'supervisor_account',
label: 'Customers'
}
],
[
MobileMenuPath.ASSETS,
{
id: MobileMenuPath.ASSETS,
icon: 'domain',
label: 'Assets'
}
],
[
MobileMenuPath.DEVICE_LIST,
{
id: MobileMenuPath.DEVICE_LIST,
icon: 'devices',
label: 'Device list'
}
],
[
MobileMenuPath.DASHBOARDS,
{
id: MobileMenuPath.DASHBOARDS,
icon: 'dashboard',
label: 'Dashboards'
}
],
[
MobileMenuPath.AUDIT_LOGS,
{
id: MobileMenuPath.AUDIT_LOGS,
icon: 'track_changes',
label: 'Audit logs'
}
],
[
MobileMenuPath.NOTIFICATIONS,
{
id: MobileMenuPath.NOTIFICATIONS,
icon: 'notifications_active',
label: 'Notification'
}
]
])

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

@ -2384,7 +2384,10 @@
"list-of-domains": "{ count, plural, =1 {One Domain} other {List of # Domains} }",
"type-mobile-app": "Mobile application",
"type-mobile-apps": "Mobile applications",
"list-of-mobile-apps": "{ count, plural, =1 {One Mobile application} other {List of # Mobile applications} }"
"list-of-mobile-apps": "{ count, plural, =1 {One Mobile application} other {List of # Mobile applications} }",
"type-mobile-app-bundle": "Mobile bundle",
"type-mobile-app-bundles": "Mobile bundles",
"list-of-mobile-app-bundles": "{ count, plural, =1 {One mobile bundle} other {List of # mobile bundles} }"
},
"entity-field": {
"created-time": "Created time",
@ -4077,6 +4080,8 @@
"copy-sha256-certificate-fingerprints": "Copy SHA256 certificate fingerprints",
"delete-applications-text": "Be careful, after the confirmation the mobile application and all related data will become unrecoverable.",
"delete-applications-title": "Are you sure you want to delete the mobile application '{{applicationName}}'?",
"delete-applications-bundle-text": "Be careful, after the confirmation the mobile bundle and all related data will become unrecoverable.",
"delete-applications-bundle-title": "Are you sure you want to delete the mobile bundle '{{bundleName}}'?",
"generate-application-secret": "Generate application secret",
"google-play-link": "Google Play link",
"google-play-link-required": "Google Play link is required",
@ -4087,9 +4092,11 @@
"mobile-package-max-length": "Application package should be less than 256",
"mobile-package-required": "Application package is required.",
"mobile-package-spaces": "Application package should not contain spaces",
"no=application": "No applications found",
"no-application": "No applications found",
"no-bundles": "No bundles found",
"platform-type": "Platform type",
"search-application": "Search applications",
"search-bundles": "Search bundles",
"set": "Set",
"sha256-certificate-fingerprints": "SHA256 certificate fingerprints",
"sha256-certificate-fingerprints-required": "SHA256 certificate fingerprints is required",
@ -4103,7 +4110,49 @@
"store-information": "Store information",
"version-information": "Version information",
"min-version-release-notes": "Min version release notes",
"latest-version-release-notes": "Latest version release notes"
"latest-version-release-notes": "Latest version release notes",
"bundles": "Bundles",
"add-bundle": "Add bundle",
"title": "Title",
"title-required": "Title is required",
"oauth-clients": "OAuth 2.0 clients",
"android-app": "Android App",
"android-application": "Android Application",
"ios-app": "iOS App",
"ios-application": "iOS Application",
"enable-oauth" : "Enable OAuth 2.0",
"enable-self-registration": "Enable Self registration",
"edit-bundle": "Edit bundle",
"description": "Description",
"basic-settings": "Basic settings",
"no-application-matching": "No application matching '{{entity}}' were found.",
"application-required": "Application is required.",
"no-application-text": "No applications found",
"layout": "Layout",
"pages": "Pages",
"hide-all-pages": "Hide all pages",
"reset-to-default-pages": "Reset to default pages",
"add-specific-page": "Add specific page",
"visible": "Visible",
"hidden": "Hidden",
"reset-to-page-default": "Reset page back to default",
"mobile-599": "Mobile (max 599px)",
"tablet-959": "Tablet (max 959px)",
"max-element-number": "Max elements number",
"page-name": "Page name",
"page-nam-required": "Page name is required.",
"page-type": "Page type",
"pages-types": {
"dashboard": "Dashboard",
"web-view": "Web view",
"custom": "Custom"
},
"url": "URL",
"path": "Path",
"custom-page": "Custom page",
"edit-page": "Edit page",
"edit-custom-page": "Edit custom page",
"delete-page": "Delete page"
},
"notification": {
"action-button": "Action button",

Loading…
Cancel
Save