diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java index 4d87530d0e..9a97b737f0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java +++ b/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) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java index 20c79688f0..3409fec793 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java +++ b/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 } diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 50978662df..373fa2c15c 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/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; } diff --git a/ui-ngx/src/app/core/http/mobile-app.service.ts b/ui-ngx/src/app/core/http/mobile-app.service.ts index ac4756f26f..37470768ca 100644 --- a/ui-ngx/src/app/core/http/mobile-app.service.ts +++ b/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(`/api/mobile/app`, mobileApp, defaultHttpOptionsFromConfig(config)); } - public getTenantMobileAppInfos(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/mobile/app${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + public getTenantMobileAppInfos(pageLink: PageLink, platformType?: PlatformType, config?: RequestConfig): Observable> { + let url = `/api/mobile/app${pageLink.toQuery()}`; + if (platformType) { + url += `&platformType=${platformType}` + } + return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } public getMobileAppInfoById(id: string, config?: RequestConfig): Observable { @@ -49,12 +54,15 @@ export class MobileAppService { } public saveMobileAppBundle(mobileAppBundle: MobileAppBundle, oauth2ClientIds?: Array, config?: RequestConfig) { - return this.http.post(`/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(url, mobileAppBundle, defaultHttpOptionsFromConfig(config)); } - public updateOauth2Clients(id: string, oauth2ClientIds?: Array, config?: RequestConfig) { - return this.http.put(`/mobile/bundle/${id}/oauth2Clients`, oauth2ClientIds, defaultHttpOptionsFromConfig(config)); + public updateOauth2Clients(id: string, oauth2ClientIds: Array, config?: RequestConfig) { + return this.http.put(`/api/mobile/bundle/${id}/oauth2Clients`, oauth2ClientIds, defaultHttpOptionsFromConfig(config)); } public getTenantMobileAppBundleInfos(pageLink: PageLink, config?: RequestConfig): Observable> { @@ -62,7 +70,11 @@ export class MobileAppService { } public getMobileAppBundleInfoById(id: string, config?: RequestConfig): Observable { - return this.http.get(`/api/mobile/bundle/infos/${id}`, defaultHttpOptionsFromConfig(config)); + return this.http.get(`/api/mobile/bundle/info/${id}`, defaultHttpOptionsFromConfig(config)); + } + + public deleteMobileAppBundle(id: string, config?: RequestConfig): Observable { + return this.http.delete(`/api/mobile/bundle/${id}`, defaultHttpOptionsFromConfig(config)); } } diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index debaefe882..0a80f4e1de 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/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([ 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([ 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([ { 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] } ] diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html index 1700938611..f6d2773637 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html @@ -173,7 +173,7 @@ (click)="$event.stopPropagation();"> - + diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts index cd9be9d79d..cee82995aa 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts +++ b/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.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) { - this.entityValue = value; - this.update(); - } + entity: BaseData; - get entity(): BaseData { - return this.entityValue; - } + @Input() + key: string; entityDetailsPrefixUrl: string; - subEntities: Array>; - - private entityValue?: BaseData; + subEntities: Array> = []; - 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); + } } } } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/applications.module.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/applications.module.ts index a3a05d6a50..eae2e5e278 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/applications.module.ts +++ b/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 { } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html new file mode 100644 index 0000000000..f3b0e82aca --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html @@ -0,0 +1,49 @@ + +
+ +

{{ 'mobile.add-application' | translate }}

+ +
+ +
+ + +
+
+ +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.ts new file mode 100644 index 0000000000..193fc4bbb5 --- /dev/null +++ b/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 implements OnDestroy, AfterViewInit, ErrorStateMatcher { + + submitted = false; + + @ViewChild('mobileAppComponent', {static: true}) mobileAppComponent: MobileAppComponent; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + 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) + ) + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.html b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.html index 1783b2b4dd..c919bb4a5d 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.html @@ -18,7 +18,7 @@
mobile.applications
- +
+ +
+ + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.scss new file mode 100644 index 0000000000..32073aa85c --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.ts new file mode 100644 index 0000000000..0183a1b118 --- /dev/null +++ b/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 { + + customMobilePage = this.fb.control(null); + + constructor(protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef, + 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); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.html new file mode 100644 index 0000000000..638dfceec8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.html @@ -0,0 +1,39 @@ + +
+
mobile.edit-custom-page
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.scss new file mode 100644 index 0000000000..fdd0108fca --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.ts new file mode 100644 index 0000000000..99f783ef48 --- /dev/null +++ b/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; + + @Output() + customMobilePageApplied = new EventEmitter(); + + mobilePageControl = this.fb.control(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); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html new file mode 100644 index 0000000000..f56fbe3a46 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html @@ -0,0 +1,68 @@ + +
+
+ + {{ 'mobile.visible' | translate }} + +
+
+ + + + mobile.page-name + + +
+ + mobile.page-type + + + {{ mobilePageTypeTranslations.get(pageType) | translate }} + + + + + + + + + mobile.url + + + + + + mobile.path + + + +
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.scss new file mode 100644 index 0000000000..5fda87092d --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.ts new file mode 100644 index 0000000000..df683cf572 --- /dev/null +++ b/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) { + 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}); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html new file mode 100644 index 0000000000..8345facaed --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html @@ -0,0 +1,64 @@ + +
+
+
mobile.edit-page
+ +
+
+
+ + {{ 'mobile.visible' | translate }} + +
+
+ + + + mobile.page-name + + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.scss new file mode 100644 index 0000000000..a8435cb97e --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts new file mode 100644 index 0000000000..f42cedd942 --- /dev/null +++ b/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; + + @Output() + defaultMobilePageApplied = new EventEmitter(); + + mobilePageFormGroup = this.fb.group({ + visible: [true], + icon: [''], + label: [''], + }); + + isCleanupEnabled = false; + defaultItemName: string; + + private defaultMobilePages: Omit; + 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(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html new file mode 100644 index 0000000000..4bc2f90b44 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html @@ -0,0 +1,108 @@ + +
+
+
+ mobile.pages +
+ + + + + +
+
+
+
+
+
+ {{ 'mobile.custom-page' | translate }} +
+ + + +
+ +
+
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.scss new file mode 100644 index 0000000000..4105d117ad --- /dev/null +++ b/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); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.ts new file mode 100644 index 0000000000..3d3f01cee1 --- /dev/null +++ b/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; + + pagesForm = this.fb.group({ + pages: this.fb.array([]) + }); + + maxIconNameBlockWidth = 256; + + private hideItemsSubject = new Subject(); + 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 { + return this.pagesFormArray().controls; + } + + mobileItemDrop(event: CdkDragDrop) { + 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, { + 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 = []; + 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); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html new file mode 100644 index 0000000000..873eb68a13 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html @@ -0,0 +1,75 @@ + +
+
+
+
+ +
+ + + +
{{ itemName }}
+
+
+
+ + + + warning + + + + + +
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.scss new file mode 100644 index 0000000000..ebee7308a3 --- /dev/null +++ b/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; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts new file mode 100644 index 0000000000..eb9f7a2d63 --- /dev/null +++ b/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; + + @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; + 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(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html new file mode 100644 index 0000000000..9d7fb0ca4e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html @@ -0,0 +1,109 @@ + + +

{{ dialogTitle | translate }}

+ +
+ +
+ + +
+
+ + + check + + + {{ 'mobile.basic-settings' | translate }} +
+ + mobile.title + + + + {{ 'mobile.title-required' | translate }} + + + + + + + + mobile.description + + +
+
+ + {{ 'admin.oauth2.oauth2' | translate }} +
+ + {{ 'mobile.enable-oauth' | translate }} + + + + +
+
+ + {{ 'mobile.layout' | translate }} +
+ + +
+
+
+
+
+ + + +
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.scss new file mode 100644 index 0000000000..3649f88d5c --- /dev/null +++ b/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; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.ts new file mode 100644 index 0000000000..8e07bcbc30 --- /dev/null +++ b/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 { + + @ViewChild('addMobileBundle', {static: true}) addMobileBundle: MatStepper; + + readonly entityType = EntityType; + + selectedIndex = 0; + + dialogTitle = 'mobile.edit-bundle'; + + stepperOrientation: Observable; + + 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, + protected router: Router, + protected dialogRef: MatDialogRef, + @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, { + 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, { + 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; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts new file mode 100644 index 0000000000..735b2b877b --- /dev/null +++ b/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 = new EntityTableConfig(); + + 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('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('title', 'mobile.title', '25%'), + new EntityChipsEntityTableColumn('oauth2ClientInfos', 'mobile.oauth-clients', '35%'), + new EntityChipsEntityTableColumn('androidPkg', 'mobile.android-app', '20%'), + new EntityChipsEntityTableColumn('iosPkg', 'mobile.ios-app', '20%'), + new EntityTableColumn('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 { + return this.config; + } + + private editBundle($event: Event, bundle: MobileAppBundleInfo, isAdd = false) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(MobileBundleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + bundle + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + private onBundleAction(action: EntityAction): boolean { + switch (action.action) { + case 'add': + this.editBundle(action.event, action.entity, true); + return true; + } + return false; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.html new file mode 100644 index 0000000000..0f4f435a15 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.html @@ -0,0 +1,28 @@ + +
+
+
mobile.bundles
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.scss new file mode 100644 index 0000000000..06f14e4712 --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-header.component.ts new file mode 100644 index 0000000000..7d97c82da0 --- /dev/null +++ b/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 { + + constructor(protected store: Store) { + super(store); + } + + createBundle($event: Event) { + this.entitiesTableConfig.onEntityAction({event: $event, action: 'add', entity: null}); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/mobile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/mobile/mobile-routing.module.ts index 4240bb7b78..6665c716b9 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/mobile-routing.module.ts +++ b/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, diff --git a/ui-ngx/src/app/modules/home/pages/mobile/mobile.module.ts b/ui-ngx/src/app/modules/home/pages/mobile/mobile.module.ts index 477fe4ae18..9c47d507c3 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/mobile.module.ts +++ b/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, ] }) diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html index 2ca193468c..c860835026 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html @@ -33,6 +33,12 @@ (click)="clear()"> close + diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index b55f01a7ae..7589f41aa2 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/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>(); + @Output() + createNew = new EventEmitter(); + @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(); + } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index bd539911c8..6b369c77e6 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + {{ labelText }} ([ [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 { diff --git a/ui-ngx/src/app/shared/models/mobile-app.models.ts b/ui-ngx/src/app/shared/models/mobile-app.models.ts index 55579d2e8c..ce07a1ef71 100644 --- a/ui-ngx/src/app/shared/models/mobile-app.models.ts +++ b/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.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, 'label'>, HasTenantId { @@ -147,5 +171,152 @@ export interface MobileAppBundle extends Omit, 'labe export interface MobileAppBundleInfo extends MobileAppBundle { androidPkgName: string; iosPkgName: string; + androidPkg?: { + name: string; + id: MobileAppId + }; + iosPkg?: { + name: string; + id: MobileAppId + } oauth2ClientInfos?: Array; + 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([ + [2, 'mobile.mobile-599'], + [4, 'mobile.tablet-959'], + [6, 'mobile.max-element-number'], +]); + +export const defaultMobilePageMap = new Map>([ + [ + 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' + } + ] +]) diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index d600843dd6..1e6e58487e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/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",