Commit 59606f8330ce2c056b88c8eac94cefab03a764c8

Authored by Igor Kulikov
1 parent fbf2d3ef

Add support for dashboboards in mobile app. Introduce widget mobile actions.

@@ -533,6 +533,37 @@ public class DashboardController extends BaseController { @@ -533,6 +533,37 @@ public class DashboardController extends BaseController {
533 } 533 }
534 } 534 }
535 535
  536 + @PreAuthorize("isAuthenticated()")
  537 + @RequestMapping(value = "/dashboard/home/info", method = RequestMethod.GET)
  538 + @ResponseBody
  539 + public HomeDashboardInfo getHomeDashboardInfo() throws ThingsboardException {
  540 + try {
  541 + SecurityUser securityUser = getCurrentUser();
  542 + if (securityUser.isSystemAdmin()) {
  543 + return null;
  544 + }
  545 + User user = userService.findUserById(securityUser.getTenantId(), securityUser.getId());
  546 + JsonNode additionalInfo = user.getAdditionalInfo();
  547 + HomeDashboardInfo homeDashboardInfo;
  548 + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo);
  549 + if (homeDashboardInfo == null) {
  550 + if (securityUser.isCustomerUser()) {
  551 + Customer customer = customerService.findCustomerById(securityUser.getTenantId(), securityUser.getCustomerId());
  552 + additionalInfo = customer.getAdditionalInfo();
  553 + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo);
  554 + }
  555 + if (homeDashboardInfo == null) {
  556 + Tenant tenant = tenantService.findTenantById(securityUser.getTenantId());
  557 + additionalInfo = tenant.getAdditionalInfo();
  558 + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo);
  559 + }
  560 + }
  561 + return homeDashboardInfo;
  562 + } catch (Exception e) {
  563 + throw handleException(e);
  564 + }
  565 + }
  566 +
536 @PreAuthorize("hasAuthority('TENANT_ADMIN')") 567 @PreAuthorize("hasAuthority('TENANT_ADMIN')")
537 @RequestMapping(value = "/tenant/dashboard/home/info", method = RequestMethod.GET) 568 @RequestMapping(value = "/tenant/dashboard/home/info", method = RequestMethod.GET)
538 @ResponseBody 569 @ResponseBody
@@ -582,6 +613,22 @@ public class DashboardController extends BaseController { @@ -582,6 +613,22 @@ public class DashboardController extends BaseController {
582 } 613 }
583 } 614 }
584 615
  616 + private HomeDashboardInfo extractHomeDashboardInfoFromAdditionalInfo(JsonNode additionalInfo) {
  617 + try {
  618 + if (additionalInfo != null && additionalInfo.has(HOME_DASHBOARD_ID) && !additionalInfo.get(HOME_DASHBOARD_ID).isNull()) {
  619 + String strDashboardId = additionalInfo.get(HOME_DASHBOARD_ID).asText();
  620 + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId));
  621 + checkDashboardId(dashboardId, Operation.READ);
  622 + boolean hideDashboardToolbar = true;
  623 + if (additionalInfo.has(HOME_DASHBOARD_HIDE_TOOLBAR)) {
  624 + hideDashboardToolbar = additionalInfo.get(HOME_DASHBOARD_HIDE_TOOLBAR).asBoolean();
  625 + }
  626 + return new HomeDashboardInfo(dashboardId, hideDashboardToolbar);
  627 + }
  628 + } catch (Exception e) {}
  629 + return null;
  630 + }
  631 +
585 private HomeDashboard extractHomeDashboardFromAdditionalInfo(JsonNode additionalInfo) { 632 private HomeDashboard extractHomeDashboardFromAdditionalInfo(JsonNode additionalInfo) {
586 try { 633 try {
587 if (additionalInfo != null && additionalInfo.has(HOME_DASHBOARD_ID) && !additionalInfo.get(HOME_DASHBOARD_ID).isNull()) { 634 if (additionalInfo != null && additionalInfo.has(HOME_DASHBOARD_ID) && !additionalInfo.get(HOME_DASHBOARD_ID).isNull()) {
  1 +///
  2 +/// Copyright © 2016-2021 The Thingsboard Authors
  3 +///
  4 +/// Licensed under the Apache License, Version 2.0 (the "License");
  5 +/// you may not use this file except in compliance with the License.
  6 +/// You may obtain a copy of the License at
  7 +///
  8 +/// http://www.apache.org/licenses/LICENSE-2.0
  9 +///
  10 +/// Unless required by applicable law or agreed to in writing, software
  11 +/// distributed under the License is distributed on an "AS IS" BASIS,
  12 +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 +/// See the License for the specific language governing permissions and
  14 +/// limitations under the License.
  15 +///
  16 +
  17 +import { Inject, Injectable } from '@angular/core';
  18 +import { WINDOW } from '@core/services/window.service';
  19 +import { isDefined } from '@core/utils';
  20 +import { MobileActionResult, WidgetMobileActionResult, WidgetMobileActionType } from '@shared/models/widget.models';
  21 +import { from, of } from 'rxjs';
  22 +import { Observable } from 'rxjs/internal/Observable';
  23 +import { catchError } from 'rxjs/operators';
  24 +
  25 +const dashboardStateNameHandler = 'tbMobileDashboardStateNameHandler';
  26 +const mobileHandler = 'tbMobileHandler';
  27 +
  28 +// @dynamic
  29 +@Injectable({
  30 + providedIn: 'root'
  31 +})
  32 +export class MobileService {
  33 +
  34 + private readonly mobileApp;
  35 + private readonly mobileChannel;
  36 +
  37 + constructor(@Inject(WINDOW) private window: Window) {
  38 + const w = (this.window as any);
  39 + this.mobileChannel = w.flutter_inappwebview;
  40 + this.mobileApp = isDefined(this.mobileChannel);
  41 + }
  42 +
  43 + public isMobileApp(): boolean {
  44 + return this.mobileApp;
  45 + }
  46 +
  47 + public handleDashboardStateName(name: string) {
  48 + if (this.mobileApp) {
  49 + this.mobileChannel.callHandler(dashboardStateNameHandler, name);
  50 + }
  51 + }
  52 +
  53 + public handleWidgetMobileAction<T extends MobileActionResult>(type: WidgetMobileActionType, ...args: any[]):
  54 + Observable<WidgetMobileActionResult<T>> {
  55 + if (this.mobileApp) {
  56 + return from(
  57 + this.mobileChannel.callHandler(mobileHandler, type, ...args) as Promise<WidgetMobileActionResult<T>>).pipe(
  58 + catchError((err: Error) => {
  59 + return of({
  60 + hasError: true,
  61 + error: err?.message ? err.message : `Failed to execute mobile action ${type}`
  62 + } as WidgetMobileActionResult<any>);
  63 + })
  64 + );
  65 + } else {
  66 + return of(null);
  67 + }
  68 + }
  69 +
  70 +}
@@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
15 limitations under the License. 15 limitations under the License.
16 16
17 --> 17 -->
18 -<div class="tb-dashboard-page mat-content" style="padding-top: 150px;" 18 +<div class="tb-dashboard-page mat-content" [ngClass]="{'mobile-app': isMobileApp && !isEdit}" style="padding-top: 150px;"
19 fxFlex tb-fullscreen [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen"> 19 fxFlex tb-fullscreen [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen">
20 <tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet> 20 <tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
21 <section class="tb-dashboard-toolbar" 21 <section class="tb-dashboard-toolbar"
@@ -25,7 +25,7 @@ @@ -25,7 +25,7 @@
25 [toolbarOpened]="toolbarOpened" (triggerClick)="openToolbar()"> 25 [toolbarOpened]="toolbarOpened" (triggerClick)="openToolbar()">
26 <div class="tb-dashboard-action-panels" fxLayout="column" fxLayout.gt-sm="row" 26 <div class="tb-dashboard-action-panels" fxLayout="column" fxLayout.gt-sm="row"
27 fxLayoutAlign="center stretch" fxLayoutAlign.gt-sm="space-between center"> 27 fxLayoutAlign="center stretch" fxLayoutAlign.gt-sm="space-between center">
28 - <div class="tb-dashboard-action-panel" fxFlex="auto" fxLayout="row-reverse" 28 + <div class="tb-dashboard-action-panel" fxFlex="auto" fxLayout="row-reverse" [fxHide]="isMobileApp && !isEdit"
29 fxLayoutAlign.gt-sm="end center" fxLayoutAlign="space-between center" fxLayoutGap="12px"> 29 fxLayoutAlign.gt-sm="end center" fxLayoutAlign="space-between center" fxLayoutGap="12px">
30 <tb-user-menu *ngIf="!isPublicUser() && forceFullscreen" fxHide.gt-sm displayUserInfo="true"> 30 <tb-user-menu *ngIf="!isPublicUser() && forceFullscreen" fxHide.gt-sm displayUserInfo="true">
31 </tb-user-menu> 31 </tb-user-menu>
@@ -49,7 +49,7 @@ @@ -49,7 +49,7 @@
49 <tb-states-component fxFlex.lt-md 49 <tb-states-component fxFlex.lt-md
50 [statesControllerId]="isEdit ? 'default' : dashboardConfiguration.settings.stateControllerId" 50 [statesControllerId]="isEdit ? 'default' : dashboardConfiguration.settings.stateControllerId"
51 [dashboardCtrl]="this" 51 [dashboardCtrl]="this"
52 - [dashboardId]="(!embedded && dashboard.id) ? dashboard.id.id : ''" 52 + [dashboardId]="setStateDashboardId ? dashboard.id.id : ''"
53 [isMobile]="isMobile" 53 [isMobile]="isMobile"
54 [state]="dashboardCtx.state" 54 [state]="dashboardCtx.state"
55 [currentState]="currentState" 55 [currentState]="currentState"
@@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
18 $toolbar-height: 50px !default; 18 $toolbar-height: 50px !default;
19 $fullscreen-toolbar-height: 64px !default; 19 $fullscreen-toolbar-height: 64px !default;
20 $mobile-toolbar-height: 84px !default; 20 $mobile-toolbar-height: 84px !default;
  21 +$mobile-app-toolbar-height: 40px !default;
21 22
22 tb-dashboard-page { 23 tb-dashboard-page {
23 display: flex; 24 display: flex;
@@ -101,6 +102,14 @@ div.tb-dashboard-page { @@ -101,6 +102,14 @@ div.tb-dashboard-page {
101 } 102 }
102 } 103 }
103 104
  105 + &.mobile-app {
  106 + .tb-dashboard-container {
  107 + &.tb-dashboard-toolbar-opened {
  108 + margin-top: $mobile-app-toolbar-height;
  109 + }
  110 + }
  111 + }
  112 +
104 mat-drawer-container.tb-dashboard-drawer-container { 113 mat-drawer-container.tb-dashboard-drawer-container {
105 mat-drawer-container.tb-dashboard-layouts { 114 mat-drawer-container.tb-dashboard-layouts {
106 width: 100%; 115 width: 100%;
@@ -120,7 +120,8 @@ import { @@ -120,7 +120,8 @@ import {
120 DisplayWidgetTypesPanelData 120 DisplayWidgetTypesPanelData
121 } from '@home/components/dashboard-page/widget-types-panel.component'; 121 } from '@home/components/dashboard-page/widget-types-panel.component';
122 import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; 122 import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component';
123 -import {AliasEntityType, EntityType} from "@shared/models/entity-type.models"; 123 +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models';
  124 +import { MobileService } from '@core/services/mobile.service';
124 125
125 // @dynamic 126 // @dynamic
126 @Component({ 127 @Component({
@@ -159,6 +160,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC @@ -159,6 +160,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
159 singlePageMode: boolean; 160 singlePageMode: boolean;
160 forceFullscreen = this.authState.forceFullscreen; 161 forceFullscreen = this.authState.forceFullscreen;
161 162
  163 + isMobileApp = this.mobileService.isMobileApp();
162 isFullscreen = false; 164 isFullscreen = false;
163 isEdit = false; 165 isEdit = false;
164 isEditingWidget = false; 166 isEditingWidget = false;
@@ -189,6 +191,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC @@ -189,6 +191,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
189 currentCustomerId: string; 191 currentCustomerId: string;
190 currentDashboardScope: DashboardPageScope; 192 currentDashboardScope: DashboardPageScope;
191 193
  194 + setStateDashboardId = false;
  195 +
192 addingLayoutCtx: DashboardPageLayoutContext; 196 addingLayoutCtx: DashboardPageLayoutContext;
193 197
194 logo = 'assets/logo_title_white.svg'; 198 logo = 'assets/logo_title_white.svg';
@@ -284,6 +288,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC @@ -284,6 +288,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
284 private dashboardService: DashboardService, 288 private dashboardService: DashboardService,
285 private itembuffer: ItemBufferService, 289 private itembuffer: ItemBufferService,
286 private importExport: ImportExportService, 290 private importExport: ImportExportService,
  291 + private mobileService: MobileService,
287 private fb: FormBuilder, 292 private fb: FormBuilder,
288 private dialog: MatDialog, 293 private dialog: MatDialog,
289 private translate: TranslateService, 294 private translate: TranslateService,
@@ -322,6 +327,19 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC @@ -322,6 +327,19 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
322 327
323 this.reset(); 328 this.reset();
324 329
  330 + this.dashboard = data.dashboard;
  331 + if (!this.embedded && this.dashboard.id) {
  332 + this.setStateDashboardId = true;
  333 + }
  334 +
  335 + if (this.route.snapshot.queryParamMap.has('hideToolbar')) {
  336 + this.hideToolbar = this.route.snapshot.queryParamMap.get('hideToolbar') === 'true';
  337 + }
  338 +
  339 + if (this.route.snapshot.queryParamMap.has('embedded')) {
  340 + this.embedded = this.route.snapshot.queryParamMap.get('embedded') === 'true';
  341 + }
  342 +
325 this.currentDashboardId = data.currentDashboardId; 343 this.currentDashboardId = data.currentDashboardId;
326 344
327 if (this.route.snapshot.params.customerId) { 345 if (this.route.snapshot.params.customerId) {
@@ -332,7 +350,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC @@ -332,7 +350,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
332 this.currentCustomerId = this.authUser.customerId; 350 this.currentCustomerId = this.authUser.customerId;
333 } 351 }
334 352
335 - this.dashboard = data.dashboard;  
336 this.dashboardConfiguration = this.dashboard.configuration; 353 this.dashboardConfiguration = this.dashboard.configuration;
337 this.dashboardCtx.dashboardTimewindow = this.dashboardConfiguration.timewindow; 354 this.dashboardCtx.dashboardTimewindow = this.dashboardConfiguration.timewindow;
338 this.layouts.main.layoutCtx.widgets = new LayoutWidgetsArray(this.dashboardCtx); 355 this.layouts.main.layoutCtx.widgets = new LayoutWidgetsArray(this.dashboardCtx);
@@ -388,6 +405,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC @@ -388,6 +405,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
388 this.currentCustomerId = null; 405 this.currentCustomerId = null;
389 this.currentDashboardScope = null; 406 this.currentDashboardScope = null;
390 407
  408 + this.setStateDashboardId = false;
  409 +
391 this.dashboardCtx.state = null; 410 this.dashboardCtx.state = null;
392 } 411 }
393 412
@@ -21,6 +21,42 @@ $mobile-toolbar-height: 80px !default; @@ -21,6 +21,42 @@ $mobile-toolbar-height: 80px !default;
21 $half-mobile-toolbar-height: 40px !default; 21 $half-mobile-toolbar-height: 40px !default;
22 $mobile-toolbar-height-total: 84px !default; 22 $mobile-toolbar-height-total: 84px !default;
23 23
  24 +$mobile-app-toolbar-height: 40px !default;
  25 +
  26 +div.tb-dashboard-page.mobile-app {
  27 + tb-dashboard-toolbar {
  28 + mat-fab-toolbar {
  29 + .mat-fab-toolbar-wrapper {
  30 + height: $mobile-app-toolbar-height;
  31 + mat-toolbar {
  32 + height: $mobile-app-toolbar-height;
  33 + min-height: $mobile-app-toolbar-height;
  34 + .mat-toolbar-tools {
  35 + height: $mobile-app-toolbar-height;
  36 + min-height: $mobile-app-toolbar-height;
  37 + }
  38 + mat-fab-actions {
  39 + height: $mobile-app-toolbar-height;
  40 + max-height: $mobile-app-toolbar-height;
  41 + .mat-fab-action-item {
  42 + height: $mobile-app-toolbar-height;
  43 + .tb-dashboard-action-panels {
  44 + height: $mobile-app-toolbar-height;
  45 + .tb-dashboard-action-panel {
  46 + height: $mobile-app-toolbar-height;
  47 + > div {
  48 + height: $mobile-app-toolbar-height;
  49 + }
  50 + }
  51 + }
  52 + }
  53 + }
  54 + }
  55 + }
  56 + }
  57 + }
  58 +}
  59 +
24 tb-dashboard-toolbar { 60 tb-dashboard-toolbar {
25 mat-fab-toolbar { 61 mat-fab-toolbar {
26 mat-fab-trigger { 62 mat-fab-trigger {
@@ -26,6 +26,7 @@ import { UtilsService } from '@core/services/utils.service'; @@ -26,6 +26,7 @@ import { UtilsService } from '@core/services/utils.service';
26 import { base64toObj, objToBase64URI } from '@app/core/utils'; 26 import { base64toObj, objToBase64URI } from '@app/core/utils';
27 import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; 27 import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
28 import { EntityService } from '@core/http/entity.service'; 28 import { EntityService } from '@core/http/entity.service';
  29 +import { MobileService } from '@core/services/mobile.service';
29 30
30 @Component({ 31 @Component({
31 selector: 'tb-default-state-controller', 32 selector: 'tb-default-state-controller',
@@ -40,6 +41,7 @@ export class DefaultStateControllerComponent extends StateControllerComponent im @@ -40,6 +41,7 @@ export class DefaultStateControllerComponent extends StateControllerComponent im
40 protected statesControllerService: StatesControllerService, 41 protected statesControllerService: StatesControllerService,
41 private utils: UtilsService, 42 private utils: UtilsService,
42 private entityService: EntityService, 43 private entityService: EntityService,
  44 + private mobileService: MobileService,
43 private dashboardUtils: DashboardUtilsService) { 45 private dashboardUtils: DashboardUtilsService) {
44 super(router, route, ngZone, statesControllerService); 46 super(router, route, ngZone, statesControllerService);
45 } 47 }
@@ -229,6 +231,9 @@ export class DefaultStateControllerComponent extends StateControllerComponent im @@ -229,6 +231,9 @@ export class DefaultStateControllerComponent extends StateControllerComponent im
229 private gotoState(stateId: string, update: boolean, openRightLayout?: boolean) { 231 private gotoState(stateId: string, update: boolean, openRightLayout?: boolean) {
230 if (this.dashboardCtrl.dashboardCtx.state !== stateId) { 232 if (this.dashboardCtrl.dashboardCtx.state !== stateId) {
231 this.dashboardCtrl.openDashboardState(stateId, openRightLayout); 233 this.dashboardCtrl.openDashboardState(stateId, openRightLayout);
  234 + if (stateId && this.statesValue[stateId]) {
  235 + this.mobileService.handleDashboardStateName(this.getStateName(stateId, this.statesValue[stateId]));
  236 + }
232 if (update) { 237 if (update) {
233 this.updateLocation(); 238 this.updateLocation();
234 } 239 }
@@ -28,6 +28,7 @@ import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; @@ -28,6 +28,7 @@ import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
28 import { EntityService } from '@core/http/entity.service'; 28 import { EntityService } from '@core/http/entity.service';
29 import { EntityType } from '@shared/models/entity-type.models'; 29 import { EntityType } from '@shared/models/entity-type.models';
30 import { map, tap } from 'rxjs/operators'; 30 import { map, tap } from 'rxjs/operators';
  31 +import { MobileService } from '@core/services/mobile.service';
31 32
32 @Component({ 33 @Component({
33 selector: 'tb-entity-state-controller', 34 selector: 'tb-entity-state-controller',
@@ -44,6 +45,7 @@ export class EntityStateControllerComponent extends StateControllerComponent imp @@ -44,6 +45,7 @@ export class EntityStateControllerComponent extends StateControllerComponent imp
44 protected statesControllerService: StatesControllerService, 45 protected statesControllerService: StatesControllerService,
45 private utils: UtilsService, 46 private utils: UtilsService,
46 private entityService: EntityService, 47 private entityService: EntityService,
  48 + private mobileService: MobileService,
47 private dashboardUtils: DashboardUtilsService) { 49 private dashboardUtils: DashboardUtilsService) {
48 super(router, route, ngZone, statesControllerService); 50 super(router, route, ngZone, statesControllerService);
49 } 51 }
@@ -270,6 +272,7 @@ export class EntityStateControllerComponent extends StateControllerComponent imp @@ -270,6 +272,7 @@ export class EntityStateControllerComponent extends StateControllerComponent imp
270 272
271 private gotoState(stateId: string, update: boolean, openRightLayout?: boolean) { 273 private gotoState(stateId: string, update: boolean, openRightLayout?: boolean) {
272 this.dashboardCtrl.openDashboardState(stateId, openRightLayout); 274 this.dashboardCtrl.openDashboardState(stateId, openRightLayout);
  275 + this.mobileService.handleDashboardStateName(this.getStateName(this.stateObject.length - 1));
273 if (update) { 276 if (update) {
274 this.updateLocation(); 277 this.updateLocation();
275 } 278 }
@@ -55,6 +55,7 @@ import { ManageWidgetActionsComponent } from '@home/components/widget/action/man @@ -55,6 +55,7 @@ import { ManageWidgetActionsComponent } from '@home/components/widget/action/man
55 import { WidgetActionDialogComponent } from '@home/components/widget/action/widget-action-dialog.component'; 55 import { WidgetActionDialogComponent } from '@home/components/widget/action/widget-action-dialog.component';
56 import { CustomActionPrettyResourcesTabsComponent } from '@home/components/widget/action/custom-action-pretty-resources-tabs.component'; 56 import { CustomActionPrettyResourcesTabsComponent } from '@home/components/widget/action/custom-action-pretty-resources-tabs.component';
57 import { CustomActionPrettyEditorComponent } from '@home/components/widget/action/custom-action-pretty-editor.component'; 57 import { CustomActionPrettyEditorComponent } from '@home/components/widget/action/custom-action-pretty-editor.component';
  58 +import { MobileActionEditorComponent } from '@home/components/widget/action/mobile-action-editor.component';
58 import { CustomDialogService } from '@home/components/widget/dialog/custom-dialog.service'; 59 import { CustomDialogService } from '@home/components/widget/dialog/custom-dialog.service';
59 import { CustomDialogContainerComponent } from '@home/components/widget/dialog/custom-dialog-container.component'; 60 import { CustomDialogContainerComponent } from '@home/components/widget/dialog/custom-dialog-container.component';
60 import { ImportExportService } from '@home/components/import-export/import-export.service'; 61 import { ImportExportService } from '@home/components/import-export/import-export.service';
@@ -183,6 +184,7 @@ import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-pag @@ -183,6 +184,7 @@ import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-pag
183 WidgetActionDialogComponent, 184 WidgetActionDialogComponent,
184 CustomActionPrettyResourcesTabsComponent, 185 CustomActionPrettyResourcesTabsComponent,
185 CustomActionPrettyEditorComponent, 186 CustomActionPrettyEditorComponent,
  187 + MobileActionEditorComponent,
186 CustomDialogContainerComponent, 188 CustomDialogContainerComponent,
187 ImportDialogComponent, 189 ImportDialogComponent,
188 ImportDialogCsvComponent, 190 ImportDialogCsvComponent,
@@ -296,6 +298,7 @@ import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-pag @@ -296,6 +298,7 @@ import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-pag
296 WidgetActionDialogComponent, 298 WidgetActionDialogComponent,
297 CustomActionPrettyResourcesTabsComponent, 299 CustomActionPrettyResourcesTabsComponent,
298 CustomActionPrettyEditorComponent, 300 CustomActionPrettyEditorComponent,
  301 + MobileActionEditorComponent,
299 CustomDialogContainerComponent, 302 CustomDialogContainerComponent,
300 ImportDialogComponent, 303 ImportDialogComponent,
301 ImportDialogCsvComponent, 304 ImportDialogCsvComponent,
  1 +<!--
  2 +
  3 + Copyright © 2016-2021 The Thingsboard Authors
  4 +
  5 + Licensed under the Apache License, Version 2.0 (the "License");
  6 + you may not use this file except in compliance with the License.
  7 + You may obtain a copy of the License at
  8 +
  9 + http://www.apache.org/licenses/LICENSE-2.0
  10 +
  11 + Unless required by applicable law or agreed to in writing, software
  12 + distributed under the License is distributed on an "AS IS" BASIS,
  13 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14 + See the License for the specific language governing permissions and
  15 + limitations under the License.
  16 +
  17 +-->
  18 +<div [formGroup]="mobileActionFormGroup">
  19 + <mat-form-field class="mat-block">
  20 + <mat-label translate>widget-action.mobile.action-type</mat-label>
  21 + <mat-select required formControlName="type">
  22 + <mat-option *ngFor="let actionType of mobileActionTypes" [value]="actionType">
  23 + {{ mobileActionTypeTranslations.get(mobileActionType[actionType]) | translate }}
  24 + </mat-option>
  25 + </mat-select>
  26 + <mat-error *ngIf="mobileActionFormGroup.get('type').hasError('required')">
  27 + {{ 'widget-action.mobile.action-type-required' | translate }}
  28 + </mat-error>
  29 + </mat-form-field>
  30 + <div [formGroup]="mobileActionTypeFormGroup" [ngSwitch]="mobileActionFormGroup.get('type').value">
  31 + <ng-template [ngSwitchCase]="mobileActionFormGroup.get('type').value === mobileActionType.mapDirection ||
  32 + mobileActionFormGroup.get('type').value === mobileActionType.mapLocation ?
  33 + mobileActionFormGroup.get('type').value : ''">
  34 + <tb-js-func
  35 + formControlName="getLocationFunction"
  36 + functionName="getLocation"
  37 + [functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
  38 + [editorCompleter]="customActionEditorCompleter"
  39 + ></tb-js-func>
  40 + </ng-template>
  41 + <ng-template [ngSwitchCase]="mobileActionType.makePhoneCall">
  42 + <tb-js-func
  43 + formControlName="getPhoneNumberFunction"
  44 + functionName="getPhoneNumber"
  45 + [functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
  46 + [editorCompleter]="customActionEditorCompleter"
  47 + ></tb-js-func>
  48 + </ng-template>
  49 + <ng-template [ngSwitchCase]="mobileActionFormGroup.get('type').value === mobileActionType.takePhoto ||
  50 + mobileActionFormGroup.get('type').value === mobileActionType.takePictureFromGallery ||
  51 + mobileActionFormGroup.get('type').value === mobileActionType.takeScreenshot ?
  52 + mobileActionFormGroup.get('type').value : ''">
  53 + <tb-js-func
  54 + formControlName="processImageFunction"
  55 + functionName="processImage"
  56 + [functionArgs]="['imageUrl', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
  57 + [editorCompleter]="customActionEditorCompleter"
  58 + ></tb-js-func>
  59 + </ng-template>
  60 + <ng-template [ngSwitchCase]="mobileActionType.scanQrCode">
  61 + <tb-js-func
  62 + formControlName="processQrCodeFunction"
  63 + functionName="processQrCode"
  64 + [functionArgs]="['code', 'format', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
  65 + [editorCompleter]="customActionEditorCompleter"
  66 + ></tb-js-func>
  67 + </ng-template>
  68 + <ng-template [ngSwitchCase]="mobileActionType.getLocation">
  69 + <tb-js-func
  70 + formControlName="processLocationFunction"
  71 + functionName="processLocation"
  72 + [functionArgs]="['latitude', 'longitude', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
  73 + [editorCompleter]="customActionEditorCompleter"
  74 + ></tb-js-func>
  75 + </ng-template>
  76 + <ng-template [ngSwitchCase]="mobileActionFormGroup.get('type').value === mobileActionType.mapDirection ||
  77 + mobileActionFormGroup.get('type').value === mobileActionType.mapLocation ||
  78 + mobileActionFormGroup.get('type').value === mobileActionType.makePhoneCall ?
  79 + mobileActionFormGroup.get('type').value : ''">
  80 + <tb-js-func
  81 + formControlName="processLaunchResultFunction"
  82 + functionName="processLaunchResult"
  83 + [functionArgs]="['launched', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
  84 + [editorCompleter]="customActionEditorCompleter"
  85 + ></tb-js-func>
  86 + </ng-template>
  87 + </div>
  88 + <tb-js-func *ngIf="mobileActionFormGroup.get('type').value"
  89 + formControlName="handleEmptyResultFunction"
  90 + functionName="handleEmptyResult"
  91 + [functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
  92 + [editorCompleter]="customActionEditorCompleter"
  93 + ></tb-js-func>
  94 + <tb-js-func *ngIf="mobileActionFormGroup.get('type').value"
  95 + formControlName="handleErrorFunction"
  96 + functionName="handleError"
  97 + [functionArgs]="['error', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
  98 + [editorCompleter]="customActionEditorCompleter"
  99 + ></tb-js-func>
  100 +</div>
  1 +///
  2 +/// Copyright © 2016-2021 The Thingsboard Authors
  3 +///
  4 +/// Licensed under the Apache License, Version 2.0 (the "License");
  5 +/// you may not use this file except in compliance with the License.
  6 +/// You may obtain a copy of the License at
  7 +///
  8 +/// http://www.apache.org/licenses/LICENSE-2.0
  9 +///
  10 +/// Unless required by applicable law or agreed to in writing, software
  11 +/// distributed under the License is distributed on an "AS IS" BASIS,
  12 +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 +/// See the License for the specific language governing permissions and
  14 +/// limitations under the License.
  15 +///
  16 +
  17 +import { Component, forwardRef, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
  18 +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
  19 +import { Store } from '@ngrx/store';
  20 +import { AppState } from '@app/core/core.state';
  21 +import { coerceBooleanProperty } from '@angular/cdk/coercion';
  22 +import { deepClone } from '@core/utils';
  23 +import {
  24 + WidgetMobileActionDescriptor,
  25 + WidgetMobileActionType, widgetMobileActionTypeTranslationMap
  26 +} from '@shared/models/widget.models';
  27 +import { CustomActionEditorCompleter } from '@home/components/widget/action/custom-action.models';
  28 +import { JsFuncComponent } from '@shared/components/js-func.component';
  29 +import {
  30 + getDefaultGetLocationFunction, getDefaultGetPhoneNumberFunction,
  31 + getDefaultHandleEmptyResultFunction,
  32 + getDefaultHandleErrorFunction,
  33 + getDefaultProcessImageFunction,
  34 + getDefaultProcessLaunchResultFunction, getDefaultProcessLocationFunction,
  35 + getDefaultProcessQrCodeFunction
  36 +} from '@home/components/widget/action/mobile-action-editor.models';
  37 +
  38 +@Component({
  39 + selector: 'tb-mobile-action-editor',
  40 + templateUrl: './mobile-action-editor.component.html',
  41 + styleUrls: [],
  42 + providers: [{
  43 + provide: NG_VALUE_ACCESSOR,
  44 + useExisting: forwardRef(() => MobileActionEditorComponent),
  45 + multi: true
  46 + }]
  47 +})
  48 +export class MobileActionEditorComponent implements ControlValueAccessor, OnInit {
  49 +
  50 + @ViewChildren(JsFuncComponent) jsFuncComponents: QueryList<JsFuncComponent>;
  51 +
  52 + mobileActionTypes = Object.keys(WidgetMobileActionType);
  53 + mobileActionTypeTranslations = widgetMobileActionTypeTranslationMap;
  54 + mobileActionType = WidgetMobileActionType;
  55 +
  56 + customActionEditorCompleter = CustomActionEditorCompleter;
  57 +
  58 + mobileActionFormGroup: FormGroup;
  59 + mobileActionTypeFormGroup: FormGroup;
  60 +
  61 + private requiredValue: boolean;
  62 + get required(): boolean {
  63 + return this.requiredValue;
  64 + }
  65 + @Input()
  66 + set required(value: boolean) {
  67 + this.requiredValue = coerceBooleanProperty(value);
  68 + }
  69 +
  70 + @Input()
  71 + disabled: boolean;
  72 +
  73 + private propagateChange = (v: any) => { };
  74 +
  75 + constructor(private store: Store<AppState>,
  76 + private fb: FormBuilder) {
  77 + }
  78 +
  79 + registerOnChange(fn: any): void {
  80 + this.propagateChange = fn;
  81 + }
  82 +
  83 + registerOnTouched(fn: any): void {
  84 + }
  85 +
  86 + ngOnInit() {
  87 + this.mobileActionFormGroup = this.fb.group({
  88 + type: [null, Validators.required],
  89 + handleEmptyResultFunction: [null],
  90 + handleErrorFunction: [null]
  91 + });
  92 + this.mobileActionFormGroup.get('type').valueChanges.subscribe((type: WidgetMobileActionType) => {
  93 + let action: WidgetMobileActionDescriptor = this.mobileActionFormGroup.value;
  94 + if (this.mobileActionTypeFormGroup) {
  95 + action = {...action, ...this.mobileActionTypeFormGroup.value};
  96 + }
  97 + this.updateMobileActionType(type, action);
  98 + });
  99 + this.mobileActionFormGroup.valueChanges.subscribe(() => {
  100 + this.updateModel();
  101 + });
  102 + }
  103 +
  104 + setDisabledState(isDisabled: boolean): void {
  105 + this.disabled = isDisabled;
  106 + if (this.disabled) {
  107 + this.mobileActionFormGroup.disable({emitEvent: false});
  108 + if (this.mobileActionTypeFormGroup) {
  109 + this.mobileActionTypeFormGroup.disable({emitEvent: false});
  110 + }
  111 + } else {
  112 + this.mobileActionFormGroup.enable({emitEvent: false});
  113 + if (this.mobileActionTypeFormGroup) {
  114 + this.mobileActionTypeFormGroup.enable({emitEvent: false});
  115 + }
  116 + }
  117 + }
  118 +
  119 + writeValue(value: WidgetMobileActionDescriptor | null): void {
  120 + this.mobileActionFormGroup.patchValue({type: value?.type,
  121 + handleEmptyResultFunction: value?.handleEmptyResultFunction,
  122 + handleErrorFunction: value?.handleErrorFunction}, {emitEvent: false});
  123 + this.updateMobileActionType(value?.type, value);
  124 + }
  125 +
  126 + private updateModel() {
  127 + let descriptor: WidgetMobileActionDescriptor = null;
  128 + if (this.mobileActionFormGroup.valid && this.mobileActionTypeFormGroup.valid) {
  129 + descriptor = { ...this.mobileActionFormGroup.getRawValue(), ...this.mobileActionTypeFormGroup.getRawValue() };
  130 + }
  131 + this.propagateChange(descriptor);
  132 + }
  133 +
  134 + private updateMobileActionType(type?: WidgetMobileActionType, action?: WidgetMobileActionDescriptor) {
  135 + const prevType = action?.type;
  136 + const targetType = type || prevType;
  137 + const changed = prevType !== type;
  138 + if (changed && targetType) {
  139 + let handleEmptyResultFunction = action?.handleEmptyResultFunction;
  140 + const defaultHandleEmptyResultFunction = getDefaultHandleEmptyResultFunction(targetType);
  141 + if (defaultHandleEmptyResultFunction !== handleEmptyResultFunction) {
  142 + handleEmptyResultFunction = getDefaultHandleEmptyResultFunction(type);
  143 + this.mobileActionFormGroup.patchValue({handleEmptyResultFunction}, {emitEvent: false});
  144 + }
  145 + let handleErrorFunction = action?.handleErrorFunction;
  146 + const defaultHandleErrorFunction = getDefaultHandleErrorFunction(targetType);
  147 + if (defaultHandleErrorFunction !== handleErrorFunction) {
  148 + handleErrorFunction = getDefaultHandleErrorFunction(type);
  149 + this.mobileActionFormGroup.patchValue({handleErrorFunction}, {emitEvent: false});
  150 + }
  151 + }
  152 + this.mobileActionTypeFormGroup = this.fb.group({});
  153 + if (type) {
  154 + switch (type) {
  155 + case WidgetMobileActionType.takePictureFromGallery:
  156 + case WidgetMobileActionType.takePhoto:
  157 + case WidgetMobileActionType.takeScreenshot:
  158 + let processImageFunction = action?.processImageFunction;
  159 + if (changed) {
  160 + const defaultProcessImageFunction = getDefaultProcessImageFunction(targetType);
  161 + if (defaultProcessImageFunction !== processImageFunction) {
  162 + processImageFunction = getDefaultProcessImageFunction(type);
  163 + }
  164 + }
  165 + this.mobileActionTypeFormGroup.addControl(
  166 + 'processImageFunction',
  167 + this.fb.control(processImageFunction, [])
  168 + );
  169 + break;
  170 + case WidgetMobileActionType.mapDirection:
  171 + case WidgetMobileActionType.mapLocation:
  172 + let getLocationFunction = action?.getLocationFunction;
  173 + let processLaunchResultFunction = action?.processLaunchResultFunction;
  174 + if (changed) {
  175 + const defaultGetLocationFunction = getDefaultGetLocationFunction();
  176 + if (defaultGetLocationFunction !== getLocationFunction) {
  177 + getLocationFunction = defaultGetLocationFunction;
  178 + }
  179 + const defaultProcessLaunchResultFunction = getDefaultProcessLaunchResultFunction(targetType);
  180 + if (defaultProcessLaunchResultFunction !== processLaunchResultFunction) {
  181 + processLaunchResultFunction = getDefaultProcessLaunchResultFunction(type);
  182 + }
  183 + }
  184 + this.mobileActionTypeFormGroup.addControl(
  185 + 'getLocationFunction',
  186 + this.fb.control(getLocationFunction, [Validators.required])
  187 + );
  188 + this.mobileActionTypeFormGroup.addControl(
  189 + 'processLaunchResultFunction',
  190 + this.fb.control(processLaunchResultFunction, [])
  191 + );
  192 + break;
  193 + case WidgetMobileActionType.scanQrCode:
  194 + let processQrCodeFunction = action?.processQrCodeFunction;
  195 + if (changed) {
  196 + const defaultProcessQrCodeFunction = getDefaultProcessQrCodeFunction();
  197 + if (defaultProcessQrCodeFunction !== processQrCodeFunction) {
  198 + processQrCodeFunction = defaultProcessQrCodeFunction;
  199 + }
  200 + }
  201 + this.mobileActionTypeFormGroup.addControl(
  202 + 'processQrCodeFunction',
  203 + this.fb.control(processQrCodeFunction, [])
  204 + );
  205 + break;
  206 + case WidgetMobileActionType.makePhoneCall:
  207 + let getPhoneNumberFunction = action?.getPhoneNumberFunction;
  208 + processLaunchResultFunction = action?.processLaunchResultFunction;
  209 + if (changed) {
  210 + const defaultGetPhoneNumberFunction = getDefaultGetPhoneNumberFunction();
  211 + if (defaultGetPhoneNumberFunction !== getPhoneNumberFunction) {
  212 + getPhoneNumberFunction = defaultGetPhoneNumberFunction;
  213 + }
  214 + const defaultProcessLaunchResultFunction = getDefaultProcessLaunchResultFunction(targetType);
  215 + if (defaultProcessLaunchResultFunction !== processLaunchResultFunction) {
  216 + processLaunchResultFunction = getDefaultProcessLaunchResultFunction(type);
  217 + }
  218 + }
  219 + this.mobileActionTypeFormGroup.addControl(
  220 + 'getPhoneNumberFunction',
  221 + this.fb.control(getPhoneNumberFunction, [Validators.required])
  222 + );
  223 + this.mobileActionTypeFormGroup.addControl(
  224 + 'processLaunchResultFunction',
  225 + this.fb.control(processLaunchResultFunction, [])
  226 + );
  227 + break;
  228 + case WidgetMobileActionType.getLocation:
  229 + let processLocationFunction = action?.processLocationFunction;
  230 + if (changed) {
  231 + const defaultProcessLocationFunction = getDefaultProcessLocationFunction();
  232 + if (defaultProcessLocationFunction !== processLocationFunction) {
  233 + processLocationFunction = defaultProcessLocationFunction;
  234 + }
  235 + }
  236 + this.mobileActionTypeFormGroup.addControl(
  237 + 'processLocationFunction',
  238 + this.fb.control(processLocationFunction, [Validators.required])
  239 + );
  240 + break;
  241 + }
  242 + }
  243 + this.mobileActionTypeFormGroup.valueChanges.subscribe(() => {
  244 + this.updateModel();
  245 + });
  246 + }
  247 +
  248 + public validateOnSubmit() {
  249 + for (const jsFuncComponent of this.jsFuncComponents.toArray()) {
  250 + jsFuncComponent.validateOnSubmit();
  251 + }
  252 + }
  253 +}
  1 +///
  2 +/// Copyright © 2016-2021 The Thingsboard Authors
  3 +///
  4 +/// Licensed under the Apache License, Version 2.0 (the "License");
  5 +/// you may not use this file except in compliance with the License.
  6 +/// You may obtain a copy of the License at
  7 +///
  8 +/// http://www.apache.org/licenses/LICENSE-2.0
  9 +///
  10 +/// Unless required by applicable law or agreed to in writing, software
  11 +/// distributed under the License is distributed on an "AS IS" BASIS,
  12 +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 +/// See the License for the specific language governing permissions and
  14 +/// limitations under the License.
  15 +///
  16 +
  17 +import { WidgetMobileActionType } from '@shared/models/widget.models';
  18 +
  19 +const processImageFunctionTemplate =
  20 + '// Function body to process image obtained as a result of mobile action (take photo, take image from gallery, etc.). \n' +
  21 + '// - imageUrl - image URL in base64 data format\n\n' +
  22 + 'showImageDialog(\'--TITLE--\', imageUrl);\n' +
  23 + '//saveEntityImageAttribute(\'image\', imageUrl);\n' +
  24 + '\n' +
  25 + 'function showImageDialog(title, imageUrl) {\n' +
  26 + ' setTimeout(function() {\n' +
  27 + ' widgetContext.customDialog.customDialog(imageDialogTemplate, ImageDialogController, {imageUrl: imageUrl, title: title}).subscribe();\n' +
  28 + ' }, 100);\n' +
  29 + '}\n' +
  30 + '\n' +
  31 + 'function saveEntityImageAttribute(attributeName, imageUrl) {\n' +
  32 + ' if (entityId) {\n' +
  33 + ' let attributes = [{\n' +
  34 + ' key: attributeName, value: imageUrl\n' +
  35 + ' }];\n' +
  36 + ' widgetContext.attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributes).subscribe(\n' +
  37 + ' function() {\n' +
  38 + ' widgetContext.showSuccessToast(\'Image attribute saved!\');\n' +
  39 + ' },\n' +
  40 + ' function(error) {\n' +
  41 + ' widgetContext.dialogs.alert(\'Image attribute save failed\', JSON.stringify(error));\n' +
  42 + ' }\n' +
  43 + ' );\n' +
  44 + ' }\n' +
  45 + '}\n' +
  46 + '\n' +
  47 + 'var\n' +
  48 + ' imageDialogTemplate =\n' +
  49 + ' \'<div aria-label="Image">\' +\n' +
  50 + ' \'<form #theForm="ngForm">\' +\n' +
  51 + ' \'<mat-toolbar fxLayout="row" color="primary">\' +\n' +
  52 + ' \'<h2>{{title}}</h2>\' +\n' +
  53 + ' \'<span fxFlex></span>\' +\n' +
  54 + ' \'<button mat-icon-button (click)="close()">\' +\n' +
  55 + ' \'<mat-icon>close</mat-icon>\' +\n' +
  56 + ' \'</button>\' +\n' +
  57 + ' \'</mat-toolbar>\' +\n' +
  58 + ' \'<div mat-dialog-content>\' +\n' +
  59 + ' \'<div class="mat-content mat-padding">\' +\n' +
  60 + ' \'<div fxLayout="column" fxFlex>\' +\n' +
  61 + ' \'<div style="padding-top: 20px;">\' +\n' +
  62 + ' \'<img [src]="imageUrl" style="height: 300px;"/>\' +\n' +
  63 + ' \'</div>\' +\n' +
  64 + ' \'</div>\' +\n' +
  65 + ' \'</div>\' +\n' +
  66 + ' \'</div>\' +\n' +
  67 + ' \'<div mat-dialog-actions fxLayout="row">\' +\n' +
  68 + ' \'<span fxFlex></span>\' +\n' +
  69 + ' \'<button mat-button (click)="close()" style="margin-right:20px;">Close</button>\' +\n' +
  70 + ' \'</div>\' +\n' +
  71 + ' \'</form>\' +\n' +
  72 + ' \'</div>\';\n' +
  73 + '\n' +
  74 + 'function ImageDialogController(instance) {\n' +
  75 + ' let vm = instance;\n' +
  76 + ' vm.title = vm.data.title;\n' +
  77 + ' vm.imageUrl = vm.data.imageUrl;\n' +
  78 + ' vm.close = function ()\n' +
  79 + ' {\n' +
  80 + ' vm.dialogRef.close(null);\n' +
  81 + ' }\n' +
  82 + '}\n';
  83 +
  84 +const processLaunchResultFunctionTemplate =
  85 + '// Optional function body to process result of attempt to launch external mobile application (for ex. map application or phone call application). \n' +
  86 + '// - launched - boolean value indicating if the external application was successfully launched.\n\n' +
  87 + 'showLaunchStatusDialog(\'--TITLE--\', launched);\n' +
  88 + '\n' +
  89 + 'function showLaunchStatusDialog(title, status) {\n' +
  90 + ' setTimeout(function() {\n' +
  91 + ' widgetContext.dialogs.alert(title, status ? \'Successfully launched\' : \'Failed to launch\').subscribe();\n' +
  92 + ' }, 100);\n' +
  93 + '}\n';
  94 +
  95 +const processQrCodeFunction =
  96 + '// Function body to process result of QR code scanning. \n' +
  97 + '// - code - scanned QR code\n' +
  98 + '// - format - scanned QR code format\n\n' +
  99 + 'showQrCodeDialog(\'QR Code\', code, format);\n' +
  100 + '\n' +
  101 + 'function showQrCodeDialog(title, code, format) {\n' +
  102 + ' setTimeout(function() {\n' +
  103 + ' widgetContext.dialogs.alert(title, \'Code: [\'+code+\']<br>Format: \' + format).subscribe();\n' +
  104 + ' }, 100);\n' +
  105 + '}\n';
  106 +
  107 +const processLocationFunction =
  108 + '// Function body to process current location of the phone. \n' +
  109 + '// - latitude - phone location latitude\n' +
  110 + '// - longitude - phone location longitude\n\n' +
  111 + 'showLocationDialog(\'Location\', latitude, longitude);\n' +
  112 + '// saveEntityLocationAttributes(\'latitude\', \'longitude\', latitude, longitude);\n' +
  113 + '\n' +
  114 + 'function showImageDialog(title, imageUrl) {\n' +
  115 + ' setTimeout(function() {\n' +
  116 + ' widgetContext.customDialog.customDialog(imageDialogTemplate, ImageDialogController, {imageUrl: imageUrl, title: title}).subscribe();\n' +
  117 + ' }, 100);\n' +
  118 + '}\n' +
  119 + '\n' +
  120 + 'function saveEntityLocationAttributes(latitudeAttributeName, longitudeAttributeName, latitude, longitude) {\n' +
  121 + ' if (entityId) {\n' +
  122 + ' let attributes = [\n' +
  123 + ' { key: latitudeAttributeName, value: latitude },\n' +
  124 + ' { key: longitudeAttributeName, value: longitude }\n' +
  125 + ' ];\n' +
  126 + ' widgetContext.attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributes).subscribe(\n' +
  127 + ' function() {\n' +
  128 + ' widgetContext.showSuccessToast(\'Location attributes saved!\');\n' +
  129 + ' },\n' +
  130 + ' function(error) {\n' +
  131 + ' widgetContext.dialogs.alert(\'Location attributes save failed\', JSON.stringify(error));\n' +
  132 + ' }\n' +
  133 + ' );\n' +
  134 + ' }\n' +
  135 + '}\n' +
  136 + '\n' +
  137 + '\n' +
  138 + 'function showLocationDialog(title, latitude, longitude) {\n' +
  139 + ' setTimeout(function() {\n' +
  140 + ' widgetContext.dialogs.alert(title, \'Latitude: \'+latitude+\'<br>Longitude: \' + longitude).subscribe();\n' +
  141 + ' }, 100);\n' +
  142 + '}';
  143 +
  144 +const handleEmptyResultFunctionTemplate =
  145 + '// Optional function body to handle empty result. \n' +
  146 + '// Usually this happens when user cancels the action (for ex. by pressing phone back button). \n\n' +
  147 + 'showEmptyResultDialog(\'--MESSAGE--\');\n' +
  148 + '\n' +
  149 + 'function showEmptyResultDialog(message) {\n' +
  150 + ' setTimeout(function() {\n' +
  151 + ' widgetContext.dialogs.alert(\'Empty result\', message).subscribe();\n' +
  152 + ' }, 100);\n' +
  153 + '}\n';
  154 +
  155 +const handleErrorFunctionTemplate =
  156 + '// Optional function body to handle error occurred while mobile action execution \n' +
  157 + '// - error - Error message\n\n' +
  158 + 'showErrorDialog(\'--TITLE--\', error);\n' +
  159 + '\n' +
  160 + 'function showErrorDialog(title, error) {\n' +
  161 + ' setTimeout(function() {\n' +
  162 + ' widgetContext.dialogs.alert(title, error).subscribe();\n' +
  163 + ' }, 100);\n' +
  164 + '}\n';
  165 +
  166 +const getLocationFunctionTemplate =
  167 + '// Function body that should return location as array of two numbers (latitude, longitude) for further processing by mobile action.\n' +
  168 + '// Usually location can be obtained from entity attributes/telemetry. \n\n' +
  169 + 'return getLocationFromEntityAttributes();\n' +
  170 + '//return [30, 30]; // TEST LOCATION\n' +
  171 + '\n' +
  172 + '\n' +
  173 + 'function getLocationFromEntityAttributes() {\n' +
  174 + ' if (entityId) {\n' +
  175 + ' return widgetContext.attributeService.getEntityAttributes(entityId, \'SERVER_SCOPE\', [\'latitude\', \'longitude\']).pipe(widgetContext.rxjs.map(function(attributeData) {\n' +
  176 + ' var res = [0,0];\n' +
  177 + ' if (attributeData && attributeData.length === 2) {\n' +
  178 + ' res[0] = attributeData.filter(function (data) { return data.key === \'latitude\'})[0].value;\n' +
  179 + ' res[1] = attributeData.filter(function (data) { return data.key === \'longitude\'})[0].value;\n' +
  180 + ' }\n' +
  181 + ' return res;\n' +
  182 + ' }));\n' +
  183 + ' } else {\n' +
  184 + ' return [0,0];\n' +
  185 + ' }\n' +
  186 + '}\n';
  187 +
  188 +const getPhoneNumberFunctionTemplate =
  189 + '// Function body that should return phone number for further processing by mobile action.\n' +
  190 + '// Usually phone number can be obtained from entity attributes/telemetry. \n\n' +
  191 + 'return getPhoneNumberFromEntityAttributes();\n' +
  192 + '//return 123456789; // TEST PHONE NUMBER\n' +
  193 + '\n' +
  194 + '\n' +
  195 + 'function getPhoneNumberFromEntityAttributes() {\n' +
  196 + ' if (entityId) {\n' +
  197 + ' return widgetContext.attributeService.getEntityAttributes(entityId, \'SERVER_SCOPE\', [\'phone\']).pipe(widgetContext.rxjs.map(function(attributeData) {\n' +
  198 + ' var res = 0;\n' +
  199 + ' if (attributeData && attributeData.length === 1) {\n' +
  200 + ' res = attributeData[0].value;\n' +
  201 + ' }\n' +
  202 + ' return res;\n' +
  203 + ' }));\n' +
  204 + ' } else {\n' +
  205 + ' return 0;\n' +
  206 + ' }\n' +
  207 + '}\n';
  208 +
  209 +export function getDefaultProcessImageFunction(type: WidgetMobileActionType): string {
  210 + let title;
  211 + switch (type) {
  212 + case WidgetMobileActionType.takePictureFromGallery:
  213 + title = 'Gallery picture';
  214 + break;
  215 + case WidgetMobileActionType.takePhoto:
  216 + title = 'Photo';
  217 + break;
  218 + case WidgetMobileActionType.takeScreenshot:
  219 + title = 'Screenshot';
  220 + break;
  221 + }
  222 + return processImageFunctionTemplate.replace('--TITLE--', title);
  223 +}
  224 +
  225 +export function getDefaultProcessLaunchResultFunction(type: WidgetMobileActionType): string {
  226 + let title;
  227 + switch (type) {
  228 + case WidgetMobileActionType.mapLocation:
  229 + title = 'Map location';
  230 + break;
  231 + case WidgetMobileActionType.mapDirection:
  232 + title = 'Map direction';
  233 + break;
  234 + case WidgetMobileActionType.makePhoneCall:
  235 + title = 'Phone call';
  236 + break;
  237 + }
  238 + return processLaunchResultFunctionTemplate.replace('--TITLE--', title);
  239 +}
  240 +
  241 +export function getDefaultProcessQrCodeFunction() {
  242 + return processQrCodeFunction;
  243 +}
  244 +
  245 +export function getDefaultProcessLocationFunction() {
  246 + return processLocationFunction;
  247 +}
  248 +
  249 +export function getDefaultGetLocationFunction() {
  250 + return getLocationFunctionTemplate;
  251 +}
  252 +
  253 +export function getDefaultGetPhoneNumberFunction() {
  254 + return getPhoneNumberFunctionTemplate;
  255 +}
  256 +
  257 +export function getDefaultHandleEmptyResultFunction(type: WidgetMobileActionType): string {
  258 + let message = 'Mobile action was cancelled!';
  259 + switch (type) {
  260 + case WidgetMobileActionType.takePictureFromGallery:
  261 + message = 'Take picture from gallery action was cancelled!';
  262 + break;
  263 + case WidgetMobileActionType.takePhoto:
  264 + message = 'Take photo action was cancelled!';
  265 + break;
  266 + case WidgetMobileActionType.mapDirection:
  267 + message = 'Open map directions was not invoked!';
  268 + break;
  269 + case WidgetMobileActionType.mapLocation:
  270 + message = 'Open location on map was not invoked!';
  271 + break;
  272 + case WidgetMobileActionType.scanQrCode:
  273 + message = 'Scan QR code action was canceled!';
  274 + break;
  275 + case WidgetMobileActionType.makePhoneCall:
  276 + message = 'Phone call was not invoked!';
  277 + break;
  278 + case WidgetMobileActionType.getLocation:
  279 + message = 'Get location action was canceled!';
  280 + break;
  281 + case WidgetMobileActionType.takeScreenshot:
  282 + message = 'Take screenshot action was cancelled!';
  283 + break;
  284 + }
  285 + return handleEmptyResultFunctionTemplate.replace('--MESSAGE--', message);
  286 +}
  287 +
  288 +export function getDefaultHandleErrorFunction(type: WidgetMobileActionType): string {
  289 + let title = 'Mobile action failed';
  290 + switch (type) {
  291 + case WidgetMobileActionType.takePictureFromGallery:
  292 + title = 'Failed to take picture from gallery';
  293 + break;
  294 + case WidgetMobileActionType.takePhoto:
  295 + title = 'Failed to take photo';
  296 + break;
  297 + case WidgetMobileActionType.mapDirection:
  298 + title = 'Failed to open map directions';
  299 + break;
  300 + case WidgetMobileActionType.mapLocation:
  301 + title = 'Failed to open map location';
  302 + break;
  303 + case WidgetMobileActionType.scanQrCode:
  304 + title = 'Failed to scan QR code';
  305 + break;
  306 + case WidgetMobileActionType.makePhoneCall:
  307 + title = 'Failed to make phone call';
  308 + break;
  309 + case WidgetMobileActionType.getLocation:
  310 + title = 'Failed to get phone location';
  311 + break;
  312 + case WidgetMobileActionType.takeScreenshot:
  313 + title = 'Failed to take screenshot';
  314 + break;
  315 + }
  316 + return handleErrorFunctionTemplate.replace('--TITLE--', title);
  317 +}
@@ -180,6 +180,11 @@ @@ -180,6 +180,11 @@
180 formControlName="customAction"> 180 formControlName="customAction">
181 </tb-custom-action-pretty-editor> 181 </tb-custom-action-pretty-editor>
182 </ng-template> 182 </ng-template>
  183 + <ng-template [ngSwitchCase]="widgetActionType.mobileAction">
  184 + <tb-mobile-action-editor #mobileActionEditor
  185 + formControlName="mobileAction">
  186 + </tb-mobile-action-editor>
  187 + </ng-template>
183 </section> 188 </section>
184 </fieldset> 189 </fieldset>
185 </div> 190 </div>
@@ -45,6 +45,7 @@ import { Dashboard } from '@shared/models/dashboard.models'; @@ -45,6 +45,7 @@ import { Dashboard } from '@shared/models/dashboard.models';
45 import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; 45 import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
46 import { CustomActionEditorCompleter } from '@home/components/widget/action/custom-action.models'; 46 import { CustomActionEditorCompleter } from '@home/components/widget/action/custom-action.models';
47 import { isDefinedAndNotNull } from '@core/utils'; 47 import { isDefinedAndNotNull } from '@core/utils';
  48 +import { MobileActionEditorComponent } from '@home/components/widget/action/mobile-action-editor.component';
48 49
49 export interface WidgetActionDialogData { 50 export interface WidgetActionDialogData {
50 isAdd: boolean; 51 isAdd: boolean;
@@ -64,6 +65,8 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia @@ -64,6 +65,8 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
64 65
65 @ViewChild('dashboardStateInput') dashboardStateInput: ElementRef; 66 @ViewChild('dashboardStateInput') dashboardStateInput: ElementRef;
66 67
  68 + @ViewChild('mobileActionEditor', {static: false}) mobileActionEditor: MobileActionEditorComponent;
  69 +
67 widgetActionFormGroup: FormGroup; 70 widgetActionFormGroup: FormGroup;
68 actionTypeFormGroup: FormGroup; 71 actionTypeFormGroup: FormGroup;
69 72
@@ -197,6 +200,12 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia @@ -197,6 +200,12 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
197 this.fb.control(toCustomAction(action), [Validators.required]) 200 this.fb.control(toCustomAction(action), [Validators.required])
198 ); 201 );
199 break; 202 break;
  203 + case WidgetActionType.mobileAction:
  204 + this.actionTypeFormGroup.addControl(
  205 + 'mobileAction',
  206 + this.fb.control(action ? action.mobileAction : null, [Validators.required])
  207 + );
  208 + break;
200 } 209 }
201 } 210 }
202 } 211 }
@@ -313,14 +322,19 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia @@ -313,14 +322,19 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
313 322
314 save(): void { 323 save(): void {
315 this.submitted = true; 324 this.submitted = true;
316 - const type: WidgetActionType = this.widgetActionFormGroup.get('type').value;  
317 - let result: WidgetActionDescriptorInfo;  
318 - if (type === WidgetActionType.customPretty) {  
319 - result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.get('customAction').value};  
320 - } else {  
321 - result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value}; 325 + if (this.mobileActionEditor != null) {
  326 + this.mobileActionEditor.validateOnSubmit();
  327 + }
  328 + if (this.widgetActionFormGroup.valid && this.actionTypeFormGroup.valid) {
  329 + const type: WidgetActionType = this.widgetActionFormGroup.get('type').value;
  330 + let result: WidgetActionDescriptorInfo;
  331 + if (type === WidgetActionType.customPretty) {
  332 + result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.get('customAction').value};
  333 + } else {
  334 + result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value};
  335 + }
  336 + result.id = this.action.id;
  337 + this.dialogRef.close(result);
322 } 338 }
323 - result.id = this.action.id;  
324 - this.dialogRef.close(result);  
325 } 339 }
326 } 340 }
@@ -39,12 +39,12 @@ import { @@ -39,12 +39,12 @@ import {
39 defaultLegendConfig, 39 defaultLegendConfig,
40 LegendConfig, 40 LegendConfig,
41 LegendData, 41 LegendData,
42 - LegendPosition, 42 + LegendPosition, MobileActionResult,
43 Widget, 43 Widget,
44 WidgetActionDescriptor, 44 WidgetActionDescriptor,
45 widgetActionSources, 45 widgetActionSources,
46 WidgetActionType, 46 WidgetActionType,
47 - WidgetComparisonSettings, 47 + WidgetComparisonSettings, WidgetMobileActionDescriptor, WidgetMobileActionType,
48 WidgetResource, 48 WidgetResource,
49 widgetType, 49 widgetType,
50 WidgetTypeParameters 50 WidgetTypeParameters
@@ -77,7 +77,7 @@ import { EntityId } from '@shared/models/id/entity-id'; @@ -77,7 +77,7 @@ import { EntityId } from '@shared/models/id/entity-id';
77 import { ActivatedRoute, Router } from '@angular/router'; 77 import { ActivatedRoute, Router } from '@angular/router';
78 import cssjs from '@core/css/css'; 78 import cssjs from '@core/css/css';
79 import { ResourcesService } from '@core/services/resources.service'; 79 import { ResourcesService } from '@core/services/resources.service';
80 -import { catchError, switchMap } from 'rxjs/operators'; 80 +import { catchError, map, switchMap } from 'rxjs/operators';
81 import { ActionNotificationShow } from '@core/notification/notification.actions'; 81 import { ActionNotificationShow } from '@core/notification/notification.actions';
82 import { TimeService } from '@core/services/time.service'; 82 import { TimeService } from '@core/services/time.service';
83 import { DeviceService } from '@app/core/http/device.service'; 83 import { DeviceService } from '@app/core/http/device.service';
@@ -97,6 +97,8 @@ import { AlarmDataService } from '@core/api/alarm-data.service'; @@ -97,6 +97,8 @@ import { AlarmDataService } from '@core/api/alarm-data.service';
97 import { MatDialog } from '@angular/material/dialog'; 97 import { MatDialog } from '@angular/material/dialog';
98 import { ComponentType } from '@angular/cdk/portal'; 98 import { ComponentType } from '@angular/cdk/portal';
99 import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token'; 99 import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token';
  100 +import { MobileService } from '@core/services/mobile.service';
  101 +import { DialogService } from '@core/services/dialog.service';
100 102
101 @Component({ 103 @Component({
102 selector: 'tb-widget', 104 selector: 'tb-widget',
@@ -177,6 +179,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI @@ -177,6 +179,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
177 private alarmDataService: AlarmDataService, 179 private alarmDataService: AlarmDataService,
178 private translate: TranslateService, 180 private translate: TranslateService,
179 private utils: UtilsService, 181 private utils: UtilsService,
  182 + private mobileService: MobileService,
  183 + private dialogs: DialogService,
180 private raf: RafService, 184 private raf: RafService,
181 private ngZone: NgZone, 185 private ngZone: NgZone,
182 private cd: ChangeDetectorRef) { 186 private cd: ChangeDetectorRef) {
@@ -1094,6 +1098,178 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI @@ -1094,6 +1098,178 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
1094 } 1098 }
1095 ); 1099 );
1096 break; 1100 break;
  1101 + case WidgetActionType.mobileAction:
  1102 + const mobileAction = descriptor.mobileAction;
  1103 + this.handleMobileAction($event, mobileAction, entityId, entityName, additionalParams, entityLabel);
  1104 + break;
  1105 + }
  1106 + }
  1107 +
  1108 + private handleMobileAction($event: Event, mobileAction: WidgetMobileActionDescriptor,
  1109 + entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string) {
  1110 + const type = mobileAction.type;
  1111 + let argsObservable: Observable<any[]>;
  1112 + switch (type) {
  1113 + case WidgetMobileActionType.takePictureFromGallery:
  1114 + case WidgetMobileActionType.takePhoto:
  1115 + case WidgetMobileActionType.scanQrCode:
  1116 + case WidgetMobileActionType.getLocation:
  1117 + case WidgetMobileActionType.takeScreenshot:
  1118 + argsObservable = of([]);
  1119 + break;
  1120 + case WidgetMobileActionType.mapDirection:
  1121 + case WidgetMobileActionType.mapLocation:
  1122 + const getLocationFunctionString = mobileAction.getLocationFunction;
  1123 + const getLocationFunction = new Function('$event', 'widgetContext', 'entityId',
  1124 + 'entityName', 'additionalParams', 'entityLabel', getLocationFunctionString);
  1125 + const locationArgs = getLocationFunction($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
  1126 + if (locationArgs && locationArgs instanceof Observable) {
  1127 + argsObservable = locationArgs;
  1128 + } else {
  1129 + argsObservable = of(locationArgs);
  1130 + }
  1131 + argsObservable = argsObservable.pipe(map(latLng => {
  1132 + let valid = false;
  1133 + if (Array.isArray(latLng) && latLng.length === 2) {
  1134 + if (typeof latLng[0] === 'number' && typeof latLng[1] === 'number') {
  1135 + valid = true;
  1136 + }
  1137 + }
  1138 + if (valid) {
  1139 + return latLng;
  1140 + } else {
  1141 + throw new Error('Location function did not return valid array of latitude/longitude!');
  1142 + }
  1143 + }));
  1144 + break;
  1145 + case WidgetMobileActionType.makePhoneCall:
  1146 + const getPhoneNumberFunctionString = mobileAction.getPhoneNumberFunction;
  1147 + const getPhoneNumberFunction = new Function('$event', 'widgetContext', 'entityId',
  1148 + 'entityName', 'additionalParams', 'entityLabel', getPhoneNumberFunctionString);
  1149 + const phoneNumberArg = getPhoneNumberFunction($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
  1150 + if (phoneNumberArg && phoneNumberArg instanceof Observable) {
  1151 + argsObservable = phoneNumberArg.pipe(map(phoneNumber => [phoneNumber]));
  1152 + } else {
  1153 + argsObservable = of([phoneNumberArg]);
  1154 + }
  1155 + argsObservable = argsObservable.pipe(map(phoneNumberArr => {
  1156 + let valid = false;
  1157 + if (Array.isArray(phoneNumberArr) && phoneNumberArr.length === 1) {
  1158 + if (phoneNumberArr[0] !== null) {
  1159 + valid = true;
  1160 + }
  1161 + }
  1162 + if (valid) {
  1163 + return phoneNumberArr;
  1164 + } else {
  1165 + throw new Error('Phone number function did not return valid number!');
  1166 + }
  1167 + }));
  1168 + break;
  1169 + }
  1170 + argsObservable.subscribe((args) => {
  1171 + this.mobileService.handleWidgetMobileAction(type, ...args).subscribe(
  1172 + (result) => {
  1173 + if (result) {
  1174 + if (result.hasError) {
  1175 + this.handleWidgetMobileActionError(result.error, $event, mobileAction, entityId, entityName, additionalParams, entityLabel);
  1176 + } else if (result.hasResult) {
  1177 + const actionResult = result.result;
  1178 + switch (type) {
  1179 + case WidgetMobileActionType.takePictureFromGallery:
  1180 + case WidgetMobileActionType.takePhoto:
  1181 + case WidgetMobileActionType.takeScreenshot:
  1182 + const imageUrl = actionResult.imageUrl;
  1183 + if (mobileAction.processImageFunction && mobileAction.processImageFunction.length) {
  1184 + try {
  1185 + const processImageFunction = new Function('imageUrl', '$event', 'widgetContext', 'entityId',
  1186 + 'entityName', 'additionalParams', 'entityLabel', mobileAction.processImageFunction);
  1187 + processImageFunction(imageUrl, $event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
  1188 + } catch (e) {
  1189 + console.error(e);
  1190 + }
  1191 + }
  1192 + break;
  1193 + case WidgetMobileActionType.scanQrCode:
  1194 + const code = actionResult.code;
  1195 + const format = actionResult.format;
  1196 + if (mobileAction.processQrCodeFunction && mobileAction.processQrCodeFunction.length) {
  1197 + try {
  1198 + const processQrCodeFunction = new Function('code', 'format', '$event', 'widgetContext', 'entityId',
  1199 + 'entityName', 'additionalParams', 'entityLabel', mobileAction.processQrCodeFunction);
  1200 + processQrCodeFunction(code, format, $event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
  1201 + } catch (e) {
  1202 + console.error(e);
  1203 + }
  1204 + }
  1205 + break;
  1206 + case WidgetMobileActionType.getLocation:
  1207 + const latitude = actionResult.latitude;
  1208 + const longitude = actionResult.longitude;
  1209 + if (mobileAction.processLocationFunction && mobileAction.processLocationFunction.length) {
  1210 + try {
  1211 + const processLocationFunction = new Function('latitude', 'longitude', '$event', 'widgetContext', 'entityId',
  1212 + 'entityName', 'additionalParams', 'entityLabel', mobileAction.processLocationFunction);
  1213 + processLocationFunction(latitude, longitude, $event, this.widgetContext,
  1214 + entityId, entityName, additionalParams, entityLabel);
  1215 + } catch (e) {
  1216 + console.error(e);
  1217 + }
  1218 + }
  1219 + break;
  1220 + case WidgetMobileActionType.mapDirection:
  1221 + case WidgetMobileActionType.mapLocation:
  1222 + case WidgetMobileActionType.makePhoneCall:
  1223 + const launched = actionResult.launched;
  1224 + if (mobileAction.processLaunchResultFunction && mobileAction.processLaunchResultFunction.length) {
  1225 + try {
  1226 + const processLaunchResultFunction = new Function('launched', '$event', 'widgetContext', 'entityId',
  1227 + 'entityName', 'additionalParams', 'entityLabel', mobileAction.processLaunchResultFunction);
  1228 + processLaunchResultFunction(launched, $event, this.widgetContext,
  1229 + entityId, entityName, additionalParams, entityLabel);
  1230 + } catch (e) {
  1231 + console.error(e);
  1232 + }
  1233 + }
  1234 + break;
  1235 + }
  1236 + } else {
  1237 + if (mobileAction.handleEmptyResultFunction && mobileAction.handleEmptyResultFunction.length) {
  1238 + try {
  1239 + const handleEmptyResultFunction = new Function('$event', 'widgetContext', 'entityId',
  1240 + 'entityName', 'additionalParams', 'entityLabel', mobileAction.handleEmptyResultFunction);
  1241 + handleEmptyResultFunction($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
  1242 + } catch (e) {
  1243 + console.error(e);
  1244 + }
  1245 + }
  1246 + }
  1247 + }
  1248 + }
  1249 + );
  1250 + },
  1251 + (err) => {
  1252 + let errorMessage;
  1253 + if (err && typeof err === 'string') {
  1254 + errorMessage = err;
  1255 + } else if (err && err.message) {
  1256 + errorMessage = err.message;
  1257 + }
  1258 + errorMessage = `Failed to get mobile action arguments${errorMessage ? `: ${errorMessage}` : '!'}`;
  1259 + this.handleWidgetMobileActionError(errorMessage, $event, mobileAction, entityId, entityName, additionalParams, entityLabel);
  1260 + });
  1261 + }
  1262 +
  1263 + private handleWidgetMobileActionError(error: string, $event: Event, mobileAction: WidgetMobileActionDescriptor,
  1264 + entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string) {
  1265 + if (mobileAction.handleErrorFunction && mobileAction.handleErrorFunction.length) {
  1266 + try {
  1267 + const handleErrorFunction = new Function('error', '$event', 'widgetContext', 'entityId',
  1268 + 'entityName', 'additionalParams', 'entityLabel', mobileAction.handleErrorFunction);
  1269 + handleErrorFunction(error, $event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
  1270 + } catch (e) {
  1271 + console.error(e);
  1272 + }
1097 } 1273 }
1098 } 1274 }
1099 1275
@@ -77,6 +77,7 @@ import { PageLink } from '@shared/models/page/page-link'; @@ -77,6 +77,7 @@ import { PageLink } from '@shared/models/page/page-link';
77 import { SortOrder } from '@shared/models/page/sort-order'; 77 import { SortOrder } from '@shared/models/page/sort-order';
78 import { DomSanitizer } from '@angular/platform-browser'; 78 import { DomSanitizer } from '@angular/platform-browser';
79 import { Router } from '@angular/router'; 79 import { Router } from '@angular/router';
  80 +import { map, mergeMap } from 'rxjs/operators';
80 81
81 export interface IWidgetAction { 82 export interface IWidgetAction {
82 name: string; 83 name: string;
@@ -238,7 +239,9 @@ export class WidgetContext { @@ -238,7 +239,9 @@ export class WidgetContext {
238 239
239 rxjs = { 240 rxjs = {
240 forkJoin, 241 forkJoin,
241 - of 242 + of,
  243 + map,
  244 + mergeMap
242 }; 245 };
243 246
244 showSuccessToast(message: string, duration: number = 1000, 247 showSuccessToast(message: string, duration: number = 1000,
@@ -332,7 +332,19 @@ export enum WidgetActionType { @@ -332,7 +332,19 @@ export enum WidgetActionType {
332 updateDashboardState = 'updateDashboardState', 332 updateDashboardState = 'updateDashboardState',
333 openDashboard = 'openDashboard', 333 openDashboard = 'openDashboard',
334 custom = 'custom', 334 custom = 'custom',
335 - customPretty = 'customPretty' 335 + customPretty = 'customPretty',
  336 + mobileAction = 'mobileAction'
  337 +}
  338 +
  339 +export enum WidgetMobileActionType {
  340 + takePictureFromGallery = 'takePictureFromGallery',
  341 + takePhoto = 'takePhoto',
  342 + mapDirection = 'mapDirection',
  343 + mapLocation = 'mapLocation',
  344 + scanQrCode = 'scanQrCode',
  345 + makePhoneCall = 'makePhoneCall',
  346 + getLocation = 'getLocation',
  347 + takeScreenshot = 'takeScreenshot'
336 } 348 }
337 349
338 export const widgetActionTypeTranslationMap = new Map<WidgetActionType, string>( 350 export const widgetActionTypeTranslationMap = new Map<WidgetActionType, string>(
@@ -341,10 +353,90 @@ export const widgetActionTypeTranslationMap = new Map<WidgetActionType, string>( @@ -341,10 +353,90 @@ export const widgetActionTypeTranslationMap = new Map<WidgetActionType, string>(
341 [ WidgetActionType.updateDashboardState, 'widget-action.update-dashboard-state' ], 353 [ WidgetActionType.updateDashboardState, 'widget-action.update-dashboard-state' ],
342 [ WidgetActionType.openDashboard, 'widget-action.open-dashboard' ], 354 [ WidgetActionType.openDashboard, 'widget-action.open-dashboard' ],
343 [ WidgetActionType.custom, 'widget-action.custom' ], 355 [ WidgetActionType.custom, 'widget-action.custom' ],
344 - [ WidgetActionType.customPretty, 'widget-action.custom-pretty' ] 356 + [ WidgetActionType.customPretty, 'widget-action.custom-pretty' ],
  357 + [ WidgetActionType.mobileAction, 'widget-action.mobile-action' ]
  358 + ]
  359 +);
  360 +
  361 +export const widgetMobileActionTypeTranslationMap = new Map<WidgetMobileActionType, string>(
  362 + [
  363 + [ WidgetMobileActionType.takePictureFromGallery, 'widget-action.mobile.take-picture-from-gallery' ],
  364 + [ WidgetMobileActionType.takePhoto, 'widget-action.mobile.take-photo' ],
  365 + [ WidgetMobileActionType.mapDirection, 'widget-action.mobile.map-direction' ],
  366 + [ WidgetMobileActionType.mapLocation, 'widget-action.mobile.map-location' ],
  367 + [ WidgetMobileActionType.scanQrCode, 'widget-action.mobile.scan-qr-code' ],
  368 + [ WidgetMobileActionType.makePhoneCall, 'widget-action.mobile.make-phone-call' ],
  369 + [ WidgetMobileActionType.getLocation, 'widget-action.mobile.get-location' ],
  370 + [ WidgetMobileActionType.takeScreenshot, 'widget-action.mobile.take-screenshot' ]
345 ] 371 ]
346 ); 372 );
347 373
  374 +export interface MobileLaunchResult {
  375 + launched: boolean;
  376 +}
  377 +
  378 +export interface MobileImageResult {
  379 + imageUrl: string;
  380 +}
  381 +
  382 +export interface MobileQrCodeResult {
  383 + code: string;
  384 + format: string;
  385 +}
  386 +
  387 +export interface MobileLocationResult {
  388 + latitude: number;
  389 + longitude: number;
  390 +}
  391 +
  392 +export type MobileActionResult = MobileLaunchResult &
  393 + MobileImageResult &
  394 + MobileQrCodeResult &
  395 + MobileLocationResult;
  396 +
  397 +export interface WidgetMobileActionResult<T extends MobileActionResult> {
  398 + result?: T;
  399 + hasResult: boolean;
  400 + error?: string;
  401 + hasError: boolean;
  402 +}
  403 +
  404 +export interface ProcessImageDescriptor {
  405 + processImageFunction: string;
  406 +}
  407 +
  408 +export interface ProcessLaunchResultDescriptor {
  409 + processLaunchResultFunction?: string;
  410 +}
  411 +
  412 +export interface LaunchMapDescriptor extends ProcessLaunchResultDescriptor {
  413 + getLocationFunction: string;
  414 +}
  415 +
  416 +export interface ScanQrCodeDescriptor {
  417 + processQrCodeFunction: string;
  418 +}
  419 +
  420 +export interface MakePhoneCallDescriptor extends ProcessLaunchResultDescriptor {
  421 + getPhoneNumberFunction: string;
  422 +}
  423 +
  424 +export interface GetLocationDescriptor {
  425 + processLocationFunction: string;
  426 +}
  427 +
  428 +export type WidgetMobileActionDescriptors = ProcessImageDescriptor &
  429 + LaunchMapDescriptor &
  430 + ScanQrCodeDescriptor &
  431 + MakePhoneCallDescriptor &
  432 + GetLocationDescriptor;
  433 +
  434 +export interface WidgetMobileActionDescriptor extends WidgetMobileActionDescriptors {
  435 + type: WidgetMobileActionType;
  436 + handleErrorFunction?: string;
  437 + handleEmptyResultFunction?: string;
  438 +}
  439 +
348 export interface CustomActionDescriptor { 440 export interface CustomActionDescriptor {
349 customFunction?: string; 441 customFunction?: string;
350 customResources?: Array<WidgetResource>; 442 customResources?: Array<WidgetResource>;
@@ -369,6 +461,7 @@ export interface WidgetActionDescriptor extends CustomActionDescriptor { @@ -369,6 +461,7 @@ export interface WidgetActionDescriptor extends CustomActionDescriptor {
369 dialogHeight?: number; 461 dialogHeight?: number;
370 setEntityId?: boolean; 462 setEntityId?: boolean;
371 stateEntityParamName?: string; 463 stateEntityParamName?: string;
  464 + mobileAction?: WidgetMobileActionDescriptor;
372 } 465 }
373 466
374 export interface WidgetComparisonSettings { 467 export interface WidgetComparisonSettings {
@@ -2704,6 +2704,7 @@ @@ -2704,6 +2704,7 @@
2704 "open-dashboard": "Navigate to other dashboard", 2704 "open-dashboard": "Navigate to other dashboard",
2705 "custom": "Custom action", 2705 "custom": "Custom action",
2706 "custom-pretty": "Custom action (with HTML template)", 2706 "custom-pretty": "Custom action (with HTML template)",
  2707 + "mobile-action": "Mobile action",
2707 "target-dashboard-state": "Target dashboard state", 2708 "target-dashboard-state": "Target dashboard state",
2708 "target-dashboard-state-required": "Target dashboard state is required", 2709 "target-dashboard-state-required": "Target dashboard state is required",
2709 "set-entity-from-widget": "Set entity from widget", 2710 "set-entity-from-widget": "Set entity from widget",
@@ -2715,7 +2716,19 @@ @@ -2715,7 +2716,19 @@
2715 "dialog-width": "Dialog width in percents relative to viewport width", 2716 "dialog-width": "Dialog width in percents relative to viewport width",
2716 "dialog-height": "Dialog height in percents relative to viewport height", 2717 "dialog-height": "Dialog height in percents relative to viewport height",
2717 "dialog-size-range-error": "Dialog size percent value should be in a range from 1 to 100.", 2718 "dialog-size-range-error": "Dialog size percent value should be in a range from 1 to 100.",
2718 - "open-new-browser-tab": "Open in a new browser tab" 2719 + "open-new-browser-tab": "Open in a new browser tab",
  2720 + "mobile": {
  2721 + "action-type": "Mobile action type",
  2722 + "action-type-required": "Mobile action type is required",
  2723 + "take-picture-from-gallery": "Take picture from gallery",
  2724 + "take-photo": "Take photo",
  2725 + "map-direction": "Open map directions",
  2726 + "map-location": "Open map location",
  2727 + "scan-qr-code": "Scan QR Code",
  2728 + "make-phone-call": "Make phone call",
  2729 + "get-location": "Get phone location",
  2730 + "take-screenshot": "Take screenshot"
  2731 + }
2719 }, 2732 },
2720 "widgets-bundle": { 2733 "widgets-bundle": {
2721 "current": "Current bundle", 2734 "current": "Current bundle",