diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 8616da8714..570f925c45 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -533,6 +533,37 @@ public class DashboardController extends BaseController { } } + @PreAuthorize("isAuthenticated()") + @RequestMapping(value = "/dashboard/home/info", method = RequestMethod.GET) + @ResponseBody + public HomeDashboardInfo getHomeDashboardInfo() throws ThingsboardException { + try { + SecurityUser securityUser = getCurrentUser(); + if (securityUser.isSystemAdmin()) { + return null; + } + User user = userService.findUserById(securityUser.getTenantId(), securityUser.getId()); + JsonNode additionalInfo = user.getAdditionalInfo(); + HomeDashboardInfo homeDashboardInfo; + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo); + if (homeDashboardInfo == null) { + if (securityUser.isCustomerUser()) { + Customer customer = customerService.findCustomerById(securityUser.getTenantId(), securityUser.getCustomerId()); + additionalInfo = customer.getAdditionalInfo(); + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo); + } + if (homeDashboardInfo == null) { + Tenant tenant = tenantService.findTenantById(securityUser.getTenantId()); + additionalInfo = tenant.getAdditionalInfo(); + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo); + } + } + return homeDashboardInfo; + } catch (Exception e) { + throw handleException(e); + } + } + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @RequestMapping(value = "/tenant/dashboard/home/info", method = RequestMethod.GET) @ResponseBody @@ -582,6 +613,22 @@ public class DashboardController extends BaseController { } } + private HomeDashboardInfo extractHomeDashboardInfoFromAdditionalInfo(JsonNode additionalInfo) { + try { + if (additionalInfo != null && additionalInfo.has(HOME_DASHBOARD_ID) && !additionalInfo.get(HOME_DASHBOARD_ID).isNull()) { + String strDashboardId = additionalInfo.get(HOME_DASHBOARD_ID).asText(); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + checkDashboardId(dashboardId, Operation.READ); + boolean hideDashboardToolbar = true; + if (additionalInfo.has(HOME_DASHBOARD_HIDE_TOOLBAR)) { + hideDashboardToolbar = additionalInfo.get(HOME_DASHBOARD_HIDE_TOOLBAR).asBoolean(); + } + return new HomeDashboardInfo(dashboardId, hideDashboardToolbar); + } + } catch (Exception e) {} + return null; + } + private HomeDashboard extractHomeDashboardFromAdditionalInfo(JsonNode additionalInfo) { try { if (additionalInfo != null && additionalInfo.has(HOME_DASHBOARD_ID) && !additionalInfo.get(HOME_DASHBOARD_ID).isNull()) { diff --git a/ui-ngx/src/app/core/services/mobile.service.ts b/ui-ngx/src/app/core/services/mobile.service.ts new file mode 100644 index 0000000000..b6774f37d0 --- /dev/null +++ b/ui-ngx/src/app/core/services/mobile.service.ts @@ -0,0 +1,70 @@ +/// +/// Copyright © 2016-2021 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 { Inject, Injectable } from '@angular/core'; +import { WINDOW } from '@core/services/window.service'; +import { isDefined } from '@core/utils'; +import { MobileActionResult, WidgetMobileActionResult, WidgetMobileActionType } from '@shared/models/widget.models'; +import { from, of } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +import { catchError } from 'rxjs/operators'; + +const dashboardStateNameHandler = 'tbMobileDashboardStateNameHandler'; +const mobileHandler = 'tbMobileHandler'; + +// @dynamic +@Injectable({ + providedIn: 'root' +}) +export class MobileService { + + private readonly mobileApp; + private readonly mobileChannel; + + constructor(@Inject(WINDOW) private window: Window) { + const w = (this.window as any); + this.mobileChannel = w.flutter_inappwebview; + this.mobileApp = isDefined(this.mobileChannel); + } + + public isMobileApp(): boolean { + return this.mobileApp; + } + + public handleDashboardStateName(name: string) { + if (this.mobileApp) { + this.mobileChannel.callHandler(dashboardStateNameHandler, name); + } + } + + public handleWidgetMobileAction(type: WidgetMobileActionType, ...args: any[]): + Observable> { + if (this.mobileApp) { + return from( + this.mobileChannel.callHandler(mobileHandler, type, ...args) as Promise>).pipe( + catchError((err: Error) => { + return of({ + hasError: true, + error: err?.message ? err.message : `Failed to execute mobile action ${type}` + } as WidgetMobileActionResult); + }) + ); + } else { + return of(null); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html index 3f03ea4fff..23555da05d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
-
@@ -49,7 +49,7 @@ div { + height: $mobile-app-toolbar-height; + } + } + } + } + } + } + } + } + } +} + tb-dashboard-toolbar { mat-fab-toolbar { mat-fab-trigger { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts index 6d2e756489..27e613a64e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts @@ -26,6 +26,7 @@ import { UtilsService } from '@core/services/utils.service'; import { base64toObj, objToBase64URI } from '@app/core/utils'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { EntityService } from '@core/http/entity.service'; +import { MobileService } from '@core/services/mobile.service'; @Component({ selector: 'tb-default-state-controller', @@ -40,6 +41,7 @@ export class DefaultStateControllerComponent extends StateControllerComponent im protected statesControllerService: StatesControllerService, private utils: UtilsService, private entityService: EntityService, + private mobileService: MobileService, private dashboardUtils: DashboardUtilsService) { super(router, route, ngZone, statesControllerService); } @@ -229,6 +231,9 @@ export class DefaultStateControllerComponent extends StateControllerComponent im private gotoState(stateId: string, update: boolean, openRightLayout?: boolean) { if (this.dashboardCtrl.dashboardCtx.state !== stateId) { this.dashboardCtrl.openDashboardState(stateId, openRightLayout); + if (stateId && this.statesValue[stateId]) { + this.mobileService.handleDashboardStateName(this.getStateName(stateId, this.statesValue[stateId])); + } if (update) { this.updateLocation(); } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts index 39b340901d..cd5628dc06 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts @@ -28,6 +28,7 @@ import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { EntityService } from '@core/http/entity.service'; import { EntityType } from '@shared/models/entity-type.models'; import { map, tap } from 'rxjs/operators'; +import { MobileService } from '@core/services/mobile.service'; @Component({ selector: 'tb-entity-state-controller', @@ -44,6 +45,7 @@ export class EntityStateControllerComponent extends StateControllerComponent imp protected statesControllerService: StatesControllerService, private utils: UtilsService, private entityService: EntityService, + private mobileService: MobileService, private dashboardUtils: DashboardUtilsService) { super(router, route, ngZone, statesControllerService); } @@ -270,6 +272,7 @@ export class EntityStateControllerComponent extends StateControllerComponent imp private gotoState(stateId: string, update: boolean, openRightLayout?: boolean) { this.dashboardCtrl.openDashboardState(stateId, openRightLayout); + this.mobileService.handleDashboardStateName(this.getStateName(this.stateObject.length - 1)); if (update) { this.updateLocation(); } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index f2208757d4..b39a566149 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -55,6 +55,7 @@ import { ManageWidgetActionsComponent } from '@home/components/widget/action/man import { WidgetActionDialogComponent } from '@home/components/widget/action/widget-action-dialog.component'; import { CustomActionPrettyResourcesTabsComponent } from '@home/components/widget/action/custom-action-pretty-resources-tabs.component'; import { CustomActionPrettyEditorComponent } from '@home/components/widget/action/custom-action-pretty-editor.component'; +import { MobileActionEditorComponent } from '@home/components/widget/action/mobile-action-editor.component'; import { CustomDialogService } from '@home/components/widget/dialog/custom-dialog.service'; import { CustomDialogContainerComponent } from '@home/components/widget/dialog/custom-dialog-container.component'; import { ImportExportService } from '@home/components/import-export/import-export.service'; @@ -183,6 +184,7 @@ import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-pag WidgetActionDialogComponent, CustomActionPrettyResourcesTabsComponent, CustomActionPrettyEditorComponent, + MobileActionEditorComponent, CustomDialogContainerComponent, ImportDialogComponent, ImportDialogCsvComponent, @@ -296,6 +298,7 @@ import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-pag WidgetActionDialogComponent, CustomActionPrettyResourcesTabsComponent, CustomActionPrettyEditorComponent, + MobileActionEditorComponent, CustomDialogContainerComponent, ImportDialogComponent, ImportDialogCsvComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html b/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html new file mode 100644 index 0000000000..cdb4615068 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html @@ -0,0 +1,100 @@ + +
+ + widget-action.mobile.action-type + + + {{ mobileActionTypeTranslations.get(mobileActionType[actionType]) | translate }} + + + + {{ 'widget-action.mobile.action-type-required' | translate }} + + +
+ + + + + + + + + + + + + + + + + + +
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.ts new file mode 100644 index 0000000000..966268faa3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.ts @@ -0,0 +1,253 @@ +/// +/// Copyright © 2016-2021 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, forwardRef, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { deepClone } from '@core/utils'; +import { + WidgetMobileActionDescriptor, + WidgetMobileActionType, widgetMobileActionTypeTranslationMap +} from '@shared/models/widget.models'; +import { CustomActionEditorCompleter } from '@home/components/widget/action/custom-action.models'; +import { JsFuncComponent } from '@shared/components/js-func.component'; +import { + getDefaultGetLocationFunction, getDefaultGetPhoneNumberFunction, + getDefaultHandleEmptyResultFunction, + getDefaultHandleErrorFunction, + getDefaultProcessImageFunction, + getDefaultProcessLaunchResultFunction, getDefaultProcessLocationFunction, + getDefaultProcessQrCodeFunction +} from '@home/components/widget/action/mobile-action-editor.models'; + +@Component({ + selector: 'tb-mobile-action-editor', + templateUrl: './mobile-action-editor.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MobileActionEditorComponent), + multi: true + }] +}) +export class MobileActionEditorComponent implements ControlValueAccessor, OnInit { + + @ViewChildren(JsFuncComponent) jsFuncComponents: QueryList; + + mobileActionTypes = Object.keys(WidgetMobileActionType); + mobileActionTypeTranslations = widgetMobileActionTypeTranslationMap; + mobileActionType = WidgetMobileActionType; + + customActionEditorCompleter = CustomActionEditorCompleter; + + mobileActionFormGroup: FormGroup; + mobileActionTypeFormGroup: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.mobileActionFormGroup = this.fb.group({ + type: [null, Validators.required], + handleEmptyResultFunction: [null], + handleErrorFunction: [null] + }); + this.mobileActionFormGroup.get('type').valueChanges.subscribe((type: WidgetMobileActionType) => { + let action: WidgetMobileActionDescriptor = this.mobileActionFormGroup.value; + if (this.mobileActionTypeFormGroup) { + action = {...action, ...this.mobileActionTypeFormGroup.value}; + } + this.updateMobileActionType(type, action); + }); + this.mobileActionFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.mobileActionFormGroup.disable({emitEvent: false}); + if (this.mobileActionTypeFormGroup) { + this.mobileActionTypeFormGroup.disable({emitEvent: false}); + } + } else { + this.mobileActionFormGroup.enable({emitEvent: false}); + if (this.mobileActionTypeFormGroup) { + this.mobileActionTypeFormGroup.enable({emitEvent: false}); + } + } + } + + writeValue(value: WidgetMobileActionDescriptor | null): void { + this.mobileActionFormGroup.patchValue({type: value?.type, + handleEmptyResultFunction: value?.handleEmptyResultFunction, + handleErrorFunction: value?.handleErrorFunction}, {emitEvent: false}); + this.updateMobileActionType(value?.type, value); + } + + private updateModel() { + let descriptor: WidgetMobileActionDescriptor = null; + if (this.mobileActionFormGroup.valid && this.mobileActionTypeFormGroup.valid) { + descriptor = { ...this.mobileActionFormGroup.getRawValue(), ...this.mobileActionTypeFormGroup.getRawValue() }; + } + this.propagateChange(descriptor); + } + + private updateMobileActionType(type?: WidgetMobileActionType, action?: WidgetMobileActionDescriptor) { + const prevType = action?.type; + const targetType = type || prevType; + const changed = prevType !== type; + if (changed && targetType) { + let handleEmptyResultFunction = action?.handleEmptyResultFunction; + const defaultHandleEmptyResultFunction = getDefaultHandleEmptyResultFunction(targetType); + if (defaultHandleEmptyResultFunction !== handleEmptyResultFunction) { + handleEmptyResultFunction = getDefaultHandleEmptyResultFunction(type); + this.mobileActionFormGroup.patchValue({handleEmptyResultFunction}, {emitEvent: false}); + } + let handleErrorFunction = action?.handleErrorFunction; + const defaultHandleErrorFunction = getDefaultHandleErrorFunction(targetType); + if (defaultHandleErrorFunction !== handleErrorFunction) { + handleErrorFunction = getDefaultHandleErrorFunction(type); + this.mobileActionFormGroup.patchValue({handleErrorFunction}, {emitEvent: false}); + } + } + this.mobileActionTypeFormGroup = this.fb.group({}); + if (type) { + switch (type) { + case WidgetMobileActionType.takePictureFromGallery: + case WidgetMobileActionType.takePhoto: + case WidgetMobileActionType.takeScreenshot: + let processImageFunction = action?.processImageFunction; + if (changed) { + const defaultProcessImageFunction = getDefaultProcessImageFunction(targetType); + if (defaultProcessImageFunction !== processImageFunction) { + processImageFunction = getDefaultProcessImageFunction(type); + } + } + this.mobileActionTypeFormGroup.addControl( + 'processImageFunction', + this.fb.control(processImageFunction, []) + ); + break; + case WidgetMobileActionType.mapDirection: + case WidgetMobileActionType.mapLocation: + let getLocationFunction = action?.getLocationFunction; + let processLaunchResultFunction = action?.processLaunchResultFunction; + if (changed) { + const defaultGetLocationFunction = getDefaultGetLocationFunction(); + if (defaultGetLocationFunction !== getLocationFunction) { + getLocationFunction = defaultGetLocationFunction; + } + const defaultProcessLaunchResultFunction = getDefaultProcessLaunchResultFunction(targetType); + if (defaultProcessLaunchResultFunction !== processLaunchResultFunction) { + processLaunchResultFunction = getDefaultProcessLaunchResultFunction(type); + } + } + this.mobileActionTypeFormGroup.addControl( + 'getLocationFunction', + this.fb.control(getLocationFunction, [Validators.required]) + ); + this.mobileActionTypeFormGroup.addControl( + 'processLaunchResultFunction', + this.fb.control(processLaunchResultFunction, []) + ); + break; + case WidgetMobileActionType.scanQrCode: + let processQrCodeFunction = action?.processQrCodeFunction; + if (changed) { + const defaultProcessQrCodeFunction = getDefaultProcessQrCodeFunction(); + if (defaultProcessQrCodeFunction !== processQrCodeFunction) { + processQrCodeFunction = defaultProcessQrCodeFunction; + } + } + this.mobileActionTypeFormGroup.addControl( + 'processQrCodeFunction', + this.fb.control(processQrCodeFunction, []) + ); + break; + case WidgetMobileActionType.makePhoneCall: + let getPhoneNumberFunction = action?.getPhoneNumberFunction; + processLaunchResultFunction = action?.processLaunchResultFunction; + if (changed) { + const defaultGetPhoneNumberFunction = getDefaultGetPhoneNumberFunction(); + if (defaultGetPhoneNumberFunction !== getPhoneNumberFunction) { + getPhoneNumberFunction = defaultGetPhoneNumberFunction; + } + const defaultProcessLaunchResultFunction = getDefaultProcessLaunchResultFunction(targetType); + if (defaultProcessLaunchResultFunction !== processLaunchResultFunction) { + processLaunchResultFunction = getDefaultProcessLaunchResultFunction(type); + } + } + this.mobileActionTypeFormGroup.addControl( + 'getPhoneNumberFunction', + this.fb.control(getPhoneNumberFunction, [Validators.required]) + ); + this.mobileActionTypeFormGroup.addControl( + 'processLaunchResultFunction', + this.fb.control(processLaunchResultFunction, []) + ); + break; + case WidgetMobileActionType.getLocation: + let processLocationFunction = action?.processLocationFunction; + if (changed) { + const defaultProcessLocationFunction = getDefaultProcessLocationFunction(); + if (defaultProcessLocationFunction !== processLocationFunction) { + processLocationFunction = defaultProcessLocationFunction; + } + } + this.mobileActionTypeFormGroup.addControl( + 'processLocationFunction', + this.fb.control(processLocationFunction, [Validators.required]) + ); + break; + } + } + this.mobileActionTypeFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + public validateOnSubmit() { + for (const jsFuncComponent of this.jsFuncComponents.toArray()) { + jsFuncComponent.validateOnSubmit(); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts b/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts new file mode 100644 index 0000000000..ea32b812be --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts @@ -0,0 +1,317 @@ +/// +/// Copyright © 2016-2021 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 { WidgetMobileActionType } from '@shared/models/widget.models'; + +const processImageFunctionTemplate = + '// Function body to process image obtained as a result of mobile action (take photo, take image from gallery, etc.). \n' + + '// - imageUrl - image URL in base64 data format\n\n' + + 'showImageDialog(\'--TITLE--\', imageUrl);\n' + + '//saveEntityImageAttribute(\'image\', imageUrl);\n' + + '\n' + + 'function showImageDialog(title, imageUrl) {\n' + + ' setTimeout(function() {\n' + + ' widgetContext.customDialog.customDialog(imageDialogTemplate, ImageDialogController, {imageUrl: imageUrl, title: title}).subscribe();\n' + + ' }, 100);\n' + + '}\n' + + '\n' + + 'function saveEntityImageAttribute(attributeName, imageUrl) {\n' + + ' if (entityId) {\n' + + ' let attributes = [{\n' + + ' key: attributeName, value: imageUrl\n' + + ' }];\n' + + ' widgetContext.attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributes).subscribe(\n' + + ' function() {\n' + + ' widgetContext.showSuccessToast(\'Image attribute saved!\');\n' + + ' },\n' + + ' function(error) {\n' + + ' widgetContext.dialogs.alert(\'Image attribute save failed\', JSON.stringify(error));\n' + + ' }\n' + + ' );\n' + + ' }\n' + + '}\n' + + '\n' + + 'var\n' + + ' imageDialogTemplate =\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'\' +\n' + + ' \'

{{title}}

\' +\n' + + ' \'\' +\n' + + ' \'\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'\' +\n' + + ' \'\' +\n' + + ' \'
\' +\n' + + ' \'
\' +\n' + + ' \'
\';\n' + + '\n' + + 'function ImageDialogController(instance) {\n' + + ' let vm = instance;\n' + + ' vm.title = vm.data.title;\n' + + ' vm.imageUrl = vm.data.imageUrl;\n' + + ' vm.close = function ()\n' + + ' {\n' + + ' vm.dialogRef.close(null);\n' + + ' }\n' + + '}\n'; + +const processLaunchResultFunctionTemplate = + '// Optional function body to process result of attempt to launch external mobile application (for ex. map application or phone call application). \n' + + '// - launched - boolean value indicating if the external application was successfully launched.\n\n' + + 'showLaunchStatusDialog(\'--TITLE--\', launched);\n' + + '\n' + + 'function showLaunchStatusDialog(title, status) {\n' + + ' setTimeout(function() {\n' + + ' widgetContext.dialogs.alert(title, status ? \'Successfully launched\' : \'Failed to launch\').subscribe();\n' + + ' }, 100);\n' + + '}\n'; + +const processQrCodeFunction = + '// Function body to process result of QR code scanning. \n' + + '// - code - scanned QR code\n' + + '// - format - scanned QR code format\n\n' + + 'showQrCodeDialog(\'QR Code\', code, format);\n' + + '\n' + + 'function showQrCodeDialog(title, code, format) {\n' + + ' setTimeout(function() {\n' + + ' widgetContext.dialogs.alert(title, \'Code: [\'+code+\']
Format: \' + format).subscribe();\n' + + ' }, 100);\n' + + '}\n'; + +const processLocationFunction = + '// Function body to process current location of the phone. \n' + + '// - latitude - phone location latitude\n' + + '// - longitude - phone location longitude\n\n' + + 'showLocationDialog(\'Location\', latitude, longitude);\n' + + '// saveEntityLocationAttributes(\'latitude\', \'longitude\', latitude, longitude);\n' + + '\n' + + 'function showImageDialog(title, imageUrl) {\n' + + ' setTimeout(function() {\n' + + ' widgetContext.customDialog.customDialog(imageDialogTemplate, ImageDialogController, {imageUrl: imageUrl, title: title}).subscribe();\n' + + ' }, 100);\n' + + '}\n' + + '\n' + + 'function saveEntityLocationAttributes(latitudeAttributeName, longitudeAttributeName, latitude, longitude) {\n' + + ' if (entityId) {\n' + + ' let attributes = [\n' + + ' { key: latitudeAttributeName, value: latitude },\n' + + ' { key: longitudeAttributeName, value: longitude }\n' + + ' ];\n' + + ' widgetContext.attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributes).subscribe(\n' + + ' function() {\n' + + ' widgetContext.showSuccessToast(\'Location attributes saved!\');\n' + + ' },\n' + + ' function(error) {\n' + + ' widgetContext.dialogs.alert(\'Location attributes save failed\', JSON.stringify(error));\n' + + ' }\n' + + ' );\n' + + ' }\n' + + '}\n' + + '\n' + + '\n' + + 'function showLocationDialog(title, latitude, longitude) {\n' + + ' setTimeout(function() {\n' + + ' widgetContext.dialogs.alert(title, \'Latitude: \'+latitude+\'
Longitude: \' + longitude).subscribe();\n' + + ' }, 100);\n' + + '}'; + +const handleEmptyResultFunctionTemplate = + '// Optional function body to handle empty result. \n' + + '// Usually this happens when user cancels the action (for ex. by pressing phone back button). \n\n' + + 'showEmptyResultDialog(\'--MESSAGE--\');\n' + + '\n' + + 'function showEmptyResultDialog(message) {\n' + + ' setTimeout(function() {\n' + + ' widgetContext.dialogs.alert(\'Empty result\', message).subscribe();\n' + + ' }, 100);\n' + + '}\n'; + +const handleErrorFunctionTemplate = + '// Optional function body to handle error occurred while mobile action execution \n' + + '// - error - Error message\n\n' + + 'showErrorDialog(\'--TITLE--\', error);\n' + + '\n' + + 'function showErrorDialog(title, error) {\n' + + ' setTimeout(function() {\n' + + ' widgetContext.dialogs.alert(title, error).subscribe();\n' + + ' }, 100);\n' + + '}\n'; + +const getLocationFunctionTemplate = + '// Function body that should return location as array of two numbers (latitude, longitude) for further processing by mobile action.\n' + + '// Usually location can be obtained from entity attributes/telemetry. \n\n' + + 'return getLocationFromEntityAttributes();\n' + + '//return [30, 30]; // TEST LOCATION\n' + + '\n' + + '\n' + + 'function getLocationFromEntityAttributes() {\n' + + ' if (entityId) {\n' + + ' return widgetContext.attributeService.getEntityAttributes(entityId, \'SERVER_SCOPE\', [\'latitude\', \'longitude\']).pipe(widgetContext.rxjs.map(function(attributeData) {\n' + + ' var res = [0,0];\n' + + ' if (attributeData && attributeData.length === 2) {\n' + + ' res[0] = attributeData.filter(function (data) { return data.key === \'latitude\'})[0].value;\n' + + ' res[1] = attributeData.filter(function (data) { return data.key === \'longitude\'})[0].value;\n' + + ' }\n' + + ' return res;\n' + + ' }));\n' + + ' } else {\n' + + ' return [0,0];\n' + + ' }\n' + + '}\n'; + +const getPhoneNumberFunctionTemplate = + '// Function body that should return phone number for further processing by mobile action.\n' + + '// Usually phone number can be obtained from entity attributes/telemetry. \n\n' + + 'return getPhoneNumberFromEntityAttributes();\n' + + '//return 123456789; // TEST PHONE NUMBER\n' + + '\n' + + '\n' + + 'function getPhoneNumberFromEntityAttributes() {\n' + + ' if (entityId) {\n' + + ' return widgetContext.attributeService.getEntityAttributes(entityId, \'SERVER_SCOPE\', [\'phone\']).pipe(widgetContext.rxjs.map(function(attributeData) {\n' + + ' var res = 0;\n' + + ' if (attributeData && attributeData.length === 1) {\n' + + ' res = attributeData[0].value;\n' + + ' }\n' + + ' return res;\n' + + ' }));\n' + + ' } else {\n' + + ' return 0;\n' + + ' }\n' + + '}\n'; + +export function getDefaultProcessImageFunction(type: WidgetMobileActionType): string { + let title; + switch (type) { + case WidgetMobileActionType.takePictureFromGallery: + title = 'Gallery picture'; + break; + case WidgetMobileActionType.takePhoto: + title = 'Photo'; + break; + case WidgetMobileActionType.takeScreenshot: + title = 'Screenshot'; + break; + } + return processImageFunctionTemplate.replace('--TITLE--', title); +} + +export function getDefaultProcessLaunchResultFunction(type: WidgetMobileActionType): string { + let title; + switch (type) { + case WidgetMobileActionType.mapLocation: + title = 'Map location'; + break; + case WidgetMobileActionType.mapDirection: + title = 'Map direction'; + break; + case WidgetMobileActionType.makePhoneCall: + title = 'Phone call'; + break; + } + return processLaunchResultFunctionTemplate.replace('--TITLE--', title); +} + +export function getDefaultProcessQrCodeFunction() { + return processQrCodeFunction; +} + +export function getDefaultProcessLocationFunction() { + return processLocationFunction; +} + +export function getDefaultGetLocationFunction() { + return getLocationFunctionTemplate; +} + +export function getDefaultGetPhoneNumberFunction() { + return getPhoneNumberFunctionTemplate; +} + +export function getDefaultHandleEmptyResultFunction(type: WidgetMobileActionType): string { + let message = 'Mobile action was cancelled!'; + switch (type) { + case WidgetMobileActionType.takePictureFromGallery: + message = 'Take picture from gallery action was cancelled!'; + break; + case WidgetMobileActionType.takePhoto: + message = 'Take photo action was cancelled!'; + break; + case WidgetMobileActionType.mapDirection: + message = 'Open map directions was not invoked!'; + break; + case WidgetMobileActionType.mapLocation: + message = 'Open location on map was not invoked!'; + break; + case WidgetMobileActionType.scanQrCode: + message = 'Scan QR code action was canceled!'; + break; + case WidgetMobileActionType.makePhoneCall: + message = 'Phone call was not invoked!'; + break; + case WidgetMobileActionType.getLocation: + message = 'Get location action was canceled!'; + break; + case WidgetMobileActionType.takeScreenshot: + message = 'Take screenshot action was cancelled!'; + break; + } + return handleEmptyResultFunctionTemplate.replace('--MESSAGE--', message); +} + +export function getDefaultHandleErrorFunction(type: WidgetMobileActionType): string { + let title = 'Mobile action failed'; + switch (type) { + case WidgetMobileActionType.takePictureFromGallery: + title = 'Failed to take picture from gallery'; + break; + case WidgetMobileActionType.takePhoto: + title = 'Failed to take photo'; + break; + case WidgetMobileActionType.mapDirection: + title = 'Failed to open map directions'; + break; + case WidgetMobileActionType.mapLocation: + title = 'Failed to open map location'; + break; + case WidgetMobileActionType.scanQrCode: + title = 'Failed to scan QR code'; + break; + case WidgetMobileActionType.makePhoneCall: + title = 'Failed to make phone call'; + break; + case WidgetMobileActionType.getLocation: + title = 'Failed to get phone location'; + break; + case WidgetMobileActionType.takeScreenshot: + title = 'Failed to take screenshot'; + break; + } + return handleErrorFunctionTemplate.replace('--TITLE--', title); +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html index 33678c88b3..44bfe44e1e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html @@ -180,6 +180,11 @@ formControlName="customAction"> + + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts index c2fc1e3cfe..0227ded562 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -45,6 +45,7 @@ import { Dashboard } from '@shared/models/dashboard.models'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { CustomActionEditorCompleter } from '@home/components/widget/action/custom-action.models'; import { isDefinedAndNotNull } from '@core/utils'; +import { MobileActionEditorComponent } from '@home/components/widget/action/mobile-action-editor.component'; export interface WidgetActionDialogData { isAdd: boolean; @@ -64,6 +65,8 @@ export class WidgetActionDialogComponent extends DialogComponent; + switch (type) { + case WidgetMobileActionType.takePictureFromGallery: + case WidgetMobileActionType.takePhoto: + case WidgetMobileActionType.scanQrCode: + case WidgetMobileActionType.getLocation: + case WidgetMobileActionType.takeScreenshot: + argsObservable = of([]); + break; + case WidgetMobileActionType.mapDirection: + case WidgetMobileActionType.mapLocation: + const getLocationFunctionString = mobileAction.getLocationFunction; + const getLocationFunction = new Function('$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel', getLocationFunctionString); + const locationArgs = getLocationFunction($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel); + if (locationArgs && locationArgs instanceof Observable) { + argsObservable = locationArgs; + } else { + argsObservable = of(locationArgs); + } + argsObservable = argsObservable.pipe(map(latLng => { + let valid = false; + if (Array.isArray(latLng) && latLng.length === 2) { + if (typeof latLng[0] === 'number' && typeof latLng[1] === 'number') { + valid = true; + } + } + if (valid) { + return latLng; + } else { + throw new Error('Location function did not return valid array of latitude/longitude!'); + } + })); + break; + case WidgetMobileActionType.makePhoneCall: + const getPhoneNumberFunctionString = mobileAction.getPhoneNumberFunction; + const getPhoneNumberFunction = new Function('$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel', getPhoneNumberFunctionString); + const phoneNumberArg = getPhoneNumberFunction($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel); + if (phoneNumberArg && phoneNumberArg instanceof Observable) { + argsObservable = phoneNumberArg.pipe(map(phoneNumber => [phoneNumber])); + } else { + argsObservable = of([phoneNumberArg]); + } + argsObservable = argsObservable.pipe(map(phoneNumberArr => { + let valid = false; + if (Array.isArray(phoneNumberArr) && phoneNumberArr.length === 1) { + if (phoneNumberArr[0] !== null) { + valid = true; + } + } + if (valid) { + return phoneNumberArr; + } else { + throw new Error('Phone number function did not return valid number!'); + } + })); + break; + } + argsObservable.subscribe((args) => { + this.mobileService.handleWidgetMobileAction(type, ...args).subscribe( + (result) => { + if (result) { + if (result.hasError) { + this.handleWidgetMobileActionError(result.error, $event, mobileAction, entityId, entityName, additionalParams, entityLabel); + } else if (result.hasResult) { + const actionResult = result.result; + switch (type) { + case WidgetMobileActionType.takePictureFromGallery: + case WidgetMobileActionType.takePhoto: + case WidgetMobileActionType.takeScreenshot: + const imageUrl = actionResult.imageUrl; + if (mobileAction.processImageFunction && mobileAction.processImageFunction.length) { + try { + const processImageFunction = new Function('imageUrl', '$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel', mobileAction.processImageFunction); + processImageFunction(imageUrl, $event, this.widgetContext, entityId, entityName, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } + } + break; + case WidgetMobileActionType.scanQrCode: + const code = actionResult.code; + const format = actionResult.format; + if (mobileAction.processQrCodeFunction && mobileAction.processQrCodeFunction.length) { + try { + const processQrCodeFunction = new Function('code', 'format', '$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel', mobileAction.processQrCodeFunction); + processQrCodeFunction(code, format, $event, this.widgetContext, entityId, entityName, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } + } + break; + case WidgetMobileActionType.getLocation: + const latitude = actionResult.latitude; + const longitude = actionResult.longitude; + if (mobileAction.processLocationFunction && mobileAction.processLocationFunction.length) { + try { + const processLocationFunction = new Function('latitude', 'longitude', '$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel', mobileAction.processLocationFunction); + processLocationFunction(latitude, longitude, $event, this.widgetContext, + entityId, entityName, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } + } + break; + case WidgetMobileActionType.mapDirection: + case WidgetMobileActionType.mapLocation: + case WidgetMobileActionType.makePhoneCall: + const launched = actionResult.launched; + if (mobileAction.processLaunchResultFunction && mobileAction.processLaunchResultFunction.length) { + try { + const processLaunchResultFunction = new Function('launched', '$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel', mobileAction.processLaunchResultFunction); + processLaunchResultFunction(launched, $event, this.widgetContext, + entityId, entityName, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } + } + break; + } + } else { + if (mobileAction.handleEmptyResultFunction && mobileAction.handleEmptyResultFunction.length) { + try { + const handleEmptyResultFunction = new Function('$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel', mobileAction.handleEmptyResultFunction); + handleEmptyResultFunction($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } + } + } + } + } + ); + }, + (err) => { + let errorMessage; + if (err && typeof err === 'string') { + errorMessage = err; + } else if (err && err.message) { + errorMessage = err.message; + } + errorMessage = `Failed to get mobile action arguments${errorMessage ? `: ${errorMessage}` : '!'}`; + this.handleWidgetMobileActionError(errorMessage, $event, mobileAction, entityId, entityName, additionalParams, entityLabel); + }); + } + + private handleWidgetMobileActionError(error: string, $event: Event, mobileAction: WidgetMobileActionDescriptor, + entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string) { + if (mobileAction.handleErrorFunction && mobileAction.handleErrorFunction.length) { + try { + const handleErrorFunction = new Function('error', '$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel', mobileAction.handleErrorFunction); + handleErrorFunction(error, $event, this.widgetContext, entityId, entityName, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } } } diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 74b6496c67..64fbe59efa 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -77,6 +77,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { SortOrder } from '@shared/models/page/sort-order'; import { DomSanitizer } from '@angular/platform-browser'; import { Router } from '@angular/router'; +import { map, mergeMap } from 'rxjs/operators'; export interface IWidgetAction { name: string; @@ -238,7 +239,9 @@ export class WidgetContext { rxjs = { forkJoin, - of + of, + map, + mergeMap }; showSuccessToast(message: string, duration: number = 1000, diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 2d599bdc78..edf0eb803a 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -332,7 +332,19 @@ export enum WidgetActionType { updateDashboardState = 'updateDashboardState', openDashboard = 'openDashboard', custom = 'custom', - customPretty = 'customPretty' + customPretty = 'customPretty', + mobileAction = 'mobileAction' +} + +export enum WidgetMobileActionType { + takePictureFromGallery = 'takePictureFromGallery', + takePhoto = 'takePhoto', + mapDirection = 'mapDirection', + mapLocation = 'mapLocation', + scanQrCode = 'scanQrCode', + makePhoneCall = 'makePhoneCall', + getLocation = 'getLocation', + takeScreenshot = 'takeScreenshot' } export const widgetActionTypeTranslationMap = new Map( @@ -341,10 +353,90 @@ export const widgetActionTypeTranslationMap = new Map( [ WidgetActionType.updateDashboardState, 'widget-action.update-dashboard-state' ], [ WidgetActionType.openDashboard, 'widget-action.open-dashboard' ], [ WidgetActionType.custom, 'widget-action.custom' ], - [ WidgetActionType.customPretty, 'widget-action.custom-pretty' ] + [ WidgetActionType.customPretty, 'widget-action.custom-pretty' ], + [ WidgetActionType.mobileAction, 'widget-action.mobile-action' ] + ] +); + +export const widgetMobileActionTypeTranslationMap = new Map( + [ + [ WidgetMobileActionType.takePictureFromGallery, 'widget-action.mobile.take-picture-from-gallery' ], + [ WidgetMobileActionType.takePhoto, 'widget-action.mobile.take-photo' ], + [ WidgetMobileActionType.mapDirection, 'widget-action.mobile.map-direction' ], + [ WidgetMobileActionType.mapLocation, 'widget-action.mobile.map-location' ], + [ WidgetMobileActionType.scanQrCode, 'widget-action.mobile.scan-qr-code' ], + [ WidgetMobileActionType.makePhoneCall, 'widget-action.mobile.make-phone-call' ], + [ WidgetMobileActionType.getLocation, 'widget-action.mobile.get-location' ], + [ WidgetMobileActionType.takeScreenshot, 'widget-action.mobile.take-screenshot' ] ] ); +export interface MobileLaunchResult { + launched: boolean; +} + +export interface MobileImageResult { + imageUrl: string; +} + +export interface MobileQrCodeResult { + code: string; + format: string; +} + +export interface MobileLocationResult { + latitude: number; + longitude: number; +} + +export type MobileActionResult = MobileLaunchResult & + MobileImageResult & + MobileQrCodeResult & + MobileLocationResult; + +export interface WidgetMobileActionResult { + result?: T; + hasResult: boolean; + error?: string; + hasError: boolean; +} + +export interface ProcessImageDescriptor { + processImageFunction: string; +} + +export interface ProcessLaunchResultDescriptor { + processLaunchResultFunction?: string; +} + +export interface LaunchMapDescriptor extends ProcessLaunchResultDescriptor { + getLocationFunction: string; +} + +export interface ScanQrCodeDescriptor { + processQrCodeFunction: string; +} + +export interface MakePhoneCallDescriptor extends ProcessLaunchResultDescriptor { + getPhoneNumberFunction: string; +} + +export interface GetLocationDescriptor { + processLocationFunction: string; +} + +export type WidgetMobileActionDescriptors = ProcessImageDescriptor & + LaunchMapDescriptor & + ScanQrCodeDescriptor & + MakePhoneCallDescriptor & + GetLocationDescriptor; + +export interface WidgetMobileActionDescriptor extends WidgetMobileActionDescriptors { + type: WidgetMobileActionType; + handleErrorFunction?: string; + handleEmptyResultFunction?: string; +} + export interface CustomActionDescriptor { customFunction?: string; customResources?: Array; @@ -369,6 +461,7 @@ export interface WidgetActionDescriptor extends CustomActionDescriptor { dialogHeight?: number; setEntityId?: boolean; stateEntityParamName?: string; + mobileAction?: WidgetMobileActionDescriptor; } export interface WidgetComparisonSettings { 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 08335a0175..160c42ff0a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2704,6 +2704,7 @@ "open-dashboard": "Navigate to other dashboard", "custom": "Custom action", "custom-pretty": "Custom action (with HTML template)", + "mobile-action": "Mobile action", "target-dashboard-state": "Target dashboard state", "target-dashboard-state-required": "Target dashboard state is required", "set-entity-from-widget": "Set entity from widget", @@ -2715,7 +2716,19 @@ "dialog-width": "Dialog width in percents relative to viewport width", "dialog-height": "Dialog height in percents relative to viewport height", "dialog-size-range-error": "Dialog size percent value should be in a range from 1 to 100.", - "open-new-browser-tab": "Open in a new browser tab" + "open-new-browser-tab": "Open in a new browser tab", + "mobile": { + "action-type": "Mobile action type", + "action-type-required": "Mobile action type is required", + "take-picture-from-gallery": "Take picture from gallery", + "take-photo": "Take photo", + "map-direction": "Open map directions", + "map-location": "Open map location", + "scan-qr-code": "Scan QR Code", + "make-phone-call": "Make phone call", + "get-location": "Get phone location", + "take-screenshot": "Take screenshot" + } }, "widgets-bundle": { "current": "Current bundle",