Browse Source

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

pull/4537/head
Igor Kulikov 5 years ago
parent
commit
59606f8330
  1. 47
      application/src/main/java/org/thingsboard/server/controller/DashboardController.java
  2. 70
      ui-ngx/src/app/core/services/mobile.service.ts
  3. 6
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html
  4. 9
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss
  5. 23
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  6. 36
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss
  7. 5
      ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts
  8. 3
      ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts
  9. 3
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  10. 100
      ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html
  11. 253
      ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.ts
  12. 317
      ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts
  13. 5
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  14. 30
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts
  15. 182
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  16. 5
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  17. 97
      ui-ngx/src/app/shared/models/widget.models.ts
  18. 15
      ui-ngx/src/assets/locale/locale.constant-en_US.json

47
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()) {

70
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<T extends MobileActionResult>(type: WidgetMobileActionType, ...args: any[]):
Observable<WidgetMobileActionResult<T>> {
if (this.mobileApp) {
return from(
this.mobileChannel.callHandler(mobileHandler, type, ...args) as Promise<WidgetMobileActionResult<T>>).pipe(
catchError((err: Error) => {
return of({
hasError: true,
error: err?.message ? err.message : `Failed to execute mobile action ${type}`
} as WidgetMobileActionResult<any>);
})
);
} else {
return of(null);
}
}
}

6
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-dashboard-page mat-content" style="padding-top: 150px;"
<div class="tb-dashboard-page mat-content" [ngClass]="{'mobile-app': isMobileApp && !isEdit}" style="padding-top: 150px;"
fxFlex tb-fullscreen [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
<section class="tb-dashboard-toolbar"
@ -25,7 +25,7 @@
[toolbarOpened]="toolbarOpened" (triggerClick)="openToolbar()">
<div class="tb-dashboard-action-panels" fxLayout="column" fxLayout.gt-sm="row"
fxLayoutAlign="center stretch" fxLayoutAlign.gt-sm="space-between center">
<div class="tb-dashboard-action-panel" fxFlex="auto" fxLayout="row-reverse"
<div class="tb-dashboard-action-panel" fxFlex="auto" fxLayout="row-reverse" [fxHide]="isMobileApp && !isEdit"
fxLayoutAlign.gt-sm="end center" fxLayoutAlign="space-between center" fxLayoutGap="12px">
<tb-user-menu *ngIf="!isPublicUser() && forceFullscreen" fxHide.gt-sm displayUserInfo="true">
</tb-user-menu>
@ -49,7 +49,7 @@
<tb-states-component fxFlex.lt-md
[statesControllerId]="isEdit ? 'default' : dashboardConfiguration.settings.stateControllerId"
[dashboardCtrl]="this"
[dashboardId]="(!embedded && dashboard.id) ? dashboard.id.id : ''"
[dashboardId]="setStateDashboardId ? dashboard.id.id : ''"
[isMobile]="isMobile"
[state]="dashboardCtx.state"
[currentState]="currentState"

9
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss

@ -18,6 +18,7 @@
$toolbar-height: 50px !default;
$fullscreen-toolbar-height: 64px !default;
$mobile-toolbar-height: 84px !default;
$mobile-app-toolbar-height: 40px !default;
tb-dashboard-page {
display: flex;
@ -101,6 +102,14 @@ div.tb-dashboard-page {
}
}
&.mobile-app {
.tb-dashboard-container {
&.tb-dashboard-toolbar-opened {
margin-top: $mobile-app-toolbar-height;
}
}
}
mat-drawer-container.tb-dashboard-drawer-container {
mat-drawer-container.tb-dashboard-layouts {
width: 100%;

23
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts

@ -120,7 +120,8 @@ import {
DisplayWidgetTypesPanelData
} from '@home/components/dashboard-page/widget-types-panel.component';
import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component';
import {AliasEntityType, EntityType} from "@shared/models/entity-type.models";
import { AliasEntityType, EntityType } from '@shared/models/entity-type.models';
import { MobileService } from '@core/services/mobile.service';
// @dynamic
@Component({
@ -159,6 +160,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
singlePageMode: boolean;
forceFullscreen = this.authState.forceFullscreen;
isMobileApp = this.mobileService.isMobileApp();
isFullscreen = false;
isEdit = false;
isEditingWidget = false;
@ -189,6 +191,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
currentCustomerId: string;
currentDashboardScope: DashboardPageScope;
setStateDashboardId = false;
addingLayoutCtx: DashboardPageLayoutContext;
logo = 'assets/logo_title_white.svg';
@ -284,6 +288,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
private dashboardService: DashboardService,
private itembuffer: ItemBufferService,
private importExport: ImportExportService,
private mobileService: MobileService,
private fb: FormBuilder,
private dialog: MatDialog,
private translate: TranslateService,
@ -322,6 +327,19 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
this.reset();
this.dashboard = data.dashboard;
if (!this.embedded && this.dashboard.id) {
this.setStateDashboardId = true;
}
if (this.route.snapshot.queryParamMap.has('hideToolbar')) {
this.hideToolbar = this.route.snapshot.queryParamMap.get('hideToolbar') === 'true';
}
if (this.route.snapshot.queryParamMap.has('embedded')) {
this.embedded = this.route.snapshot.queryParamMap.get('embedded') === 'true';
}
this.currentDashboardId = data.currentDashboardId;
if (this.route.snapshot.params.customerId) {
@ -332,7 +350,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
this.currentCustomerId = this.authUser.customerId;
}
this.dashboard = data.dashboard;
this.dashboardConfiguration = this.dashboard.configuration;
this.dashboardCtx.dashboardTimewindow = this.dashboardConfiguration.timewindow;
this.layouts.main.layoutCtx.widgets = new LayoutWidgetsArray(this.dashboardCtx);
@ -388,6 +405,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
this.currentCustomerId = null;
this.currentDashboardScope = null;
this.setStateDashboardId = false;
this.dashboardCtx.state = null;
}

36
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss

@ -21,6 +21,42 @@ $mobile-toolbar-height: 80px !default;
$half-mobile-toolbar-height: 40px !default;
$mobile-toolbar-height-total: 84px !default;
$mobile-app-toolbar-height: 40px !default;
div.tb-dashboard-page.mobile-app {
tb-dashboard-toolbar {
mat-fab-toolbar {
.mat-fab-toolbar-wrapper {
height: $mobile-app-toolbar-height;
mat-toolbar {
height: $mobile-app-toolbar-height;
min-height: $mobile-app-toolbar-height;
.mat-toolbar-tools {
height: $mobile-app-toolbar-height;
min-height: $mobile-app-toolbar-height;
}
mat-fab-actions {
height: $mobile-app-toolbar-height;
max-height: $mobile-app-toolbar-height;
.mat-fab-action-item {
height: $mobile-app-toolbar-height;
.tb-dashboard-action-panels {
height: $mobile-app-toolbar-height;
.tb-dashboard-action-panel {
height: $mobile-app-toolbar-height;
> div {
height: $mobile-app-toolbar-height;
}
}
}
}
}
}
}
}
}
}
tb-dashboard-toolbar {
mat-fab-toolbar {
mat-fab-trigger {

5
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();
}

3
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();
}

3
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,

100
ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html

@ -0,0 +1,100 @@
<!--
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.
-->
<div [formGroup]="mobileActionFormGroup">
<mat-form-field class="mat-block">
<mat-label translate>widget-action.mobile.action-type</mat-label>
<mat-select required formControlName="type">
<mat-option *ngFor="let actionType of mobileActionTypes" [value]="actionType">
{{ mobileActionTypeTranslations.get(mobileActionType[actionType]) | translate }}
</mat-option>
</mat-select>
<mat-error *ngIf="mobileActionFormGroup.get('type').hasError('required')">
{{ 'widget-action.mobile.action-type-required' | translate }}
</mat-error>
</mat-form-field>
<div [formGroup]="mobileActionTypeFormGroup" [ngSwitch]="mobileActionFormGroup.get('type').value">
<ng-template [ngSwitchCase]="mobileActionFormGroup.get('type').value === mobileActionType.mapDirection ||
mobileActionFormGroup.get('type').value === mobileActionType.mapLocation ?
mobileActionFormGroup.get('type').value : ''">
<tb-js-func
formControlName="getLocationFunction"
functionName="getLocation"
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[editorCompleter]="customActionEditorCompleter"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.makePhoneCall">
<tb-js-func
formControlName="getPhoneNumberFunction"
functionName="getPhoneNumber"
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[editorCompleter]="customActionEditorCompleter"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionFormGroup.get('type').value === mobileActionType.takePhoto ||
mobileActionFormGroup.get('type').value === mobileActionType.takePictureFromGallery ||
mobileActionFormGroup.get('type').value === mobileActionType.takeScreenshot ?
mobileActionFormGroup.get('type').value : ''">
<tb-js-func
formControlName="processImageFunction"
functionName="processImage"
[functionArgs]="['imageUrl', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[editorCompleter]="customActionEditorCompleter"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.scanQrCode">
<tb-js-func
formControlName="processQrCodeFunction"
functionName="processQrCode"
[functionArgs]="['code', 'format', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[editorCompleter]="customActionEditorCompleter"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.getLocation">
<tb-js-func
formControlName="processLocationFunction"
functionName="processLocation"
[functionArgs]="['latitude', 'longitude', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[editorCompleter]="customActionEditorCompleter"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionFormGroup.get('type').value === mobileActionType.mapDirection ||
mobileActionFormGroup.get('type').value === mobileActionType.mapLocation ||
mobileActionFormGroup.get('type').value === mobileActionType.makePhoneCall ?
mobileActionFormGroup.get('type').value : ''">
<tb-js-func
formControlName="processLaunchResultFunction"
functionName="processLaunchResult"
[functionArgs]="['launched', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[editorCompleter]="customActionEditorCompleter"
></tb-js-func>
</ng-template>
</div>
<tb-js-func *ngIf="mobileActionFormGroup.get('type').value"
formControlName="handleEmptyResultFunction"
functionName="handleEmptyResult"
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[editorCompleter]="customActionEditorCompleter"
></tb-js-func>
<tb-js-func *ngIf="mobileActionFormGroup.get('type').value"
formControlName="handleErrorFunction"
functionName="handleError"
[functionArgs]="['error', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[editorCompleter]="customActionEditorCompleter"
></tb-js-func>
</div>

253
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<JsFuncComponent>;
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<AppState>,
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();
}
}
}

317
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' +
' \'<div aria-label="Image">\' +\n' +
' \'<form #theForm="ngForm">\' +\n' +
' \'<mat-toolbar fxLayout="row" color="primary">\' +\n' +
' \'<h2>{{title}}</h2>\' +\n' +
' \'<span fxFlex></span>\' +\n' +
' \'<button mat-icon-button (click)="close()">\' +\n' +
' \'<mat-icon>close</mat-icon>\' +\n' +
' \'</button>\' +\n' +
' \'</mat-toolbar>\' +\n' +
' \'<div mat-dialog-content>\' +\n' +
' \'<div class="mat-content mat-padding">\' +\n' +
' \'<div fxLayout="column" fxFlex>\' +\n' +
' \'<div style="padding-top: 20px;">\' +\n' +
' \'<img [src]="imageUrl" style="height: 300px;"/>\' +\n' +
' \'</div>\' +\n' +
' \'</div>\' +\n' +
' \'</div>\' +\n' +
' \'</div>\' +\n' +
' \'<div mat-dialog-actions fxLayout="row">\' +\n' +
' \'<span fxFlex></span>\' +\n' +
' \'<button mat-button (click)="close()" style="margin-right:20px;">Close</button>\' +\n' +
' \'</div>\' +\n' +
' \'</form>\' +\n' +
' \'</div>\';\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+\']<br>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+\'<br>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);
}

5
ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html

@ -180,6 +180,11 @@
formControlName="customAction">
</tb-custom-action-pretty-editor>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.mobileAction">
<tb-mobile-action-editor #mobileActionEditor
formControlName="mobileAction">
</tb-mobile-action-editor>
</ng-template>
</section>
</fieldset>
</div>

30
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<WidgetActionDia
@ViewChild('dashboardStateInput') dashboardStateInput: ElementRef;
@ViewChild('mobileActionEditor', {static: false}) mobileActionEditor: MobileActionEditorComponent;
widgetActionFormGroup: FormGroup;
actionTypeFormGroup: FormGroup;
@ -197,6 +200,12 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
this.fb.control(toCustomAction(action), [Validators.required])
);
break;
case WidgetActionType.mobileAction:
this.actionTypeFormGroup.addControl(
'mobileAction',
this.fb.control(action ? action.mobileAction : null, [Validators.required])
);
break;
}
}
}
@ -313,14 +322,19 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
save(): void {
this.submitted = true;
const type: WidgetActionType = this.widgetActionFormGroup.get('type').value;
let result: WidgetActionDescriptorInfo;
if (type === WidgetActionType.customPretty) {
result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.get('customAction').value};
} else {
result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value};
if (this.mobileActionEditor != null) {
this.mobileActionEditor.validateOnSubmit();
}
if (this.widgetActionFormGroup.valid && this.actionTypeFormGroup.valid) {
const type: WidgetActionType = this.widgetActionFormGroup.get('type').value;
let result: WidgetActionDescriptorInfo;
if (type === WidgetActionType.customPretty) {
result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.get('customAction').value};
} else {
result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value};
}
result.id = this.action.id;
this.dialogRef.close(result);
}
result.id = this.action.id;
this.dialogRef.close(result);
}
}

182
ui-ngx/src/app/modules/home/components/widget/widget.component.ts

@ -39,12 +39,12 @@ import {
defaultLegendConfig,
LegendConfig,
LegendData,
LegendPosition,
LegendPosition, MobileActionResult,
Widget,
WidgetActionDescriptor,
widgetActionSources,
WidgetActionType,
WidgetComparisonSettings,
WidgetComparisonSettings, WidgetMobileActionDescriptor, WidgetMobileActionType,
WidgetResource,
widgetType,
WidgetTypeParameters
@ -77,7 +77,7 @@ import { EntityId } from '@shared/models/id/entity-id';
import { ActivatedRoute, Router } from '@angular/router';
import cssjs from '@core/css/css';
import { ResourcesService } from '@core/services/resources.service';
import { catchError, switchMap } from 'rxjs/operators';
import { catchError, map, switchMap } from 'rxjs/operators';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { TimeService } from '@core/services/time.service';
import { DeviceService } from '@app/core/http/device.service';
@ -97,6 +97,8 @@ import { AlarmDataService } from '@core/api/alarm-data.service';
import { MatDialog } from '@angular/material/dialog';
import { ComponentType } from '@angular/cdk/portal';
import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token';
import { MobileService } from '@core/services/mobile.service';
import { DialogService } from '@core/services/dialog.service';
@Component({
selector: 'tb-widget',
@ -177,6 +179,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
private alarmDataService: AlarmDataService,
private translate: TranslateService,
private utils: UtilsService,
private mobileService: MobileService,
private dialogs: DialogService,
private raf: RafService,
private ngZone: NgZone,
private cd: ChangeDetectorRef) {
@ -1094,6 +1098,178 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
}
);
break;
case WidgetActionType.mobileAction:
const mobileAction = descriptor.mobileAction;
this.handleMobileAction($event, mobileAction, entityId, entityName, additionalParams, entityLabel);
break;
}
}
private handleMobileAction($event: Event, mobileAction: WidgetMobileActionDescriptor,
entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string) {
const type = mobileAction.type;
let argsObservable: Observable<any[]>;
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);
}
}
}

5
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,

97
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<WidgetActionType, string>(
@ -341,10 +353,90 @@ export const widgetActionTypeTranslationMap = new Map<WidgetActionType, string>(
[ 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, string>(
[
[ 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<T extends MobileActionResult> {
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<WidgetResource>;
@ -369,6 +461,7 @@ export interface WidgetActionDescriptor extends CustomActionDescriptor {
dialogHeight?: number;
setEntityId?: boolean;
stateEntityParamName?: string;
mobileAction?: WidgetMobileActionDescriptor;
}
export interface WidgetComparisonSettings {

15
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",

Loading…
Cancel
Save