UI: Improve mobile action

This commit is contained in:
Artem Dzhereleiko 2025-08-11 12:57:28 +03:00 committed by ArtemDzhereleiko
parent 609a68c991
commit c24401603a
6 changed files with 292 additions and 120 deletions

View File

@ -35,117 +35,73 @@
</mat-icon>
</mat-form-field>
</div>
<ng-container [formGroup]="mobileActionTypeFormGroup" [ngSwitch]="mobileActionFormGroup.get('type').value">
<ng-template [ngSwitchCase]="mobileActionType.deviceProvision">
<tb-js-func
formControlName="handleProvisionSuccessFunction"
functionName="handleProvisionSuccess"
withModules
[functionArgs]="['deviceName', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
></tb-js-func>
</ng-template>
<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"
withModules
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
helpId="widget/action/mobile_get_location_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.makePhoneCall">
<tb-js-func
formControlName="getPhoneNumberFunction"
functionName="getPhoneNumber"
withModules
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
helpId="widget/action/mobile_get_phone_number_fn"
></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"
withModules
[functionArgs]="['imageUrl', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
helpId="widget/action/mobile_process_image_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.scanQrCode">
<tb-js-func
formControlName="processQrCodeFunction"
functionName="processQrCode"
withModules
[functionArgs]="['code', 'format', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
helpId="widget/action/mobile_process_qr_code_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.getLocation">
<tb-js-func
formControlName="processLocationFunction"
functionName="processLocation"
withModules
[functionArgs]="['latitude', 'longitude', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
helpId="widget/action/mobile_process_location_fn"
></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"
withModules
[functionArgs]="['launched', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
helpId="widget/action/mobile_process_launch_result_fn"
></tb-js-func>
</ng-template>
<ng-container [formGroup]="mobileActionTypeFormGroup">
@if (mobileActionFormGroup.get('type').value === mobileActionType.takePhoto ||
mobileActionFormGroup.get('type').value === mobileActionType.takePictureFromGallery ||
mobileActionFormGroup.get('type').value === mobileActionType.takeScreenshot) {
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="saveToGallery">
{{ 'widget-action.mobile.save-to-gallery' | translate }}
</mat-slide-toggle>
</div>
}
@if (mobileActionFormGroup.get('type').value === mobileActionType.deviceProvision) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'widget-action.mobile.provision-type' | translate }}*</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="provisionType">
<mat-option *ngFor="let type of provisionTypes" [value]="type">
{{ provisionTypeTranslationMap.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
}
@for (config of actionConfig; track config.formControlName) {
<div class="tb-form-panel stroked">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header>
<mat-panel-description class="flex items-stretch justify-start">
{{ config.title | translate }}
</mat-panel-description>
</mat-expansion-panel-header>
<tb-js-func
[formControlName]="config.formControlName"
[functionName]="config.functionName"
withModules
[functionArgs]="config.functionArgs"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
[helpId]="config.helpId"
></tb-js-func>
</mat-expansion-panel>
</div>
}
</ng-container>
<tb-js-func *ngIf="mobileActionFormGroup.get('type').value"
formControlName="handleEmptyResultFunction"
functionName="handleEmptyResult"
withModules
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
helpId="widget/action/mobile_handle_empty_result_fn"
></tb-js-func>
<tb-js-func *ngIf="mobileActionFormGroup.get('type').value"
formControlName="handleErrorFunction"
functionName="handleError"
withModules
[functionArgs]="['error', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
helpId="widget/action/mobile_handle_error_fn"
></tb-js-func>
@if(mobileActionFormGroup.get('type').value) {
@for (config of commonActionConfig; track config.formControlName) {
<div class="tb-form-panel stroked">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header>
<mat-panel-description class="flex items-stretch justify-start">
{{ config.title | translate }}
</mat-panel-description>
</mat-expansion-panel-header>
<tb-js-func
[formControlName]="config.formControlName"
[functionName]="config.functionName"
withModules
[functionArgs]="config.functionArgs"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
hideBrackets
[helpId]="config.helpId"
></tb-js-func>
</mat-expansion-panel>
</div>
}
}
</div>

View File

@ -24,6 +24,9 @@ import {
} from '@angular/forms';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
ActionConfig,
ProvisionType,
provisionTypeTranslationMap,
WidgetActionType,
WidgetMobileActionDescriptor,
WidgetMobileActionType,
@ -35,6 +38,7 @@ import {
getDefaultGetPhoneNumberFunction,
getDefaultHandleEmptyResultFunction,
getDefaultHandleErrorFunction,
getDefaultHandleNonMobileFallBackFunction,
getDefaultProcessImageFunction,
getDefaultProcessLaunchResultFunction,
getDefaultProcessLocationFunction,
@ -68,6 +72,12 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit
functionScopeVariables: string[];
actionConfig: ActionConfig[];
commonActionConfig: ActionConfig[];
provisionTypes: string[] = Object.keys(ProvisionType);
provisionTypeTranslationMap = provisionTypeTranslationMap;
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
@ -99,8 +109,10 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit
this.mobileActionFormGroup = this.fb.group({
type: [null, Validators.required],
handleEmptyResultFunction: [null],
handleErrorFunction: [null]
handleErrorFunction: [null],
handleNonMobileFallbackFunction: [null]
});
this.getCommonActionConfigs();
this.mobileActionFormGroup.get('type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((type: WidgetMobileActionType) => {
@ -109,6 +121,7 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit
action = {...action, ...this.mobileActionTypeFormGroup.value};
}
this.updateMobileActionType(type, action);
this.getActionConfigs();
});
this.mobileActionFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
@ -133,10 +146,14 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit
}
writeValue(value: WidgetMobileActionDescriptor | null): void {
this.mobileActionFormGroup.patchValue({type: value?.type,
handleEmptyResultFunction: value?.handleEmptyResultFunction,
handleErrorFunction: value?.handleErrorFunction}, {emitEvent: false});
this.mobileActionFormGroup.patchValue({
type: value?.type,
handleEmptyResultFunction: value?.handleEmptyResultFunction,
handleErrorFunction: value?.handleErrorFunction,
handleNonMobileFallbackFunction: value?.handleNonMobileFallbackFunction
}, {emitEvent: false});
this.updateMobileActionType(value?.type, value);
this.getActionConfigs();
}
private updateModel() {
@ -164,6 +181,12 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit
handleErrorFunction = getDefaultHandleErrorFunction(type);
this.mobileActionFormGroup.patchValue({handleErrorFunction}, {emitEvent: false});
}
let handleNonMobileFallbackFunction = action?.handleNonMobileFallbackFunction;
const defaultHandleNonMobileFallbackFunction = getDefaultHandleNonMobileFallBackFunction();
if (defaultHandleNonMobileFallbackFunction !== handleNonMobileFallbackFunction) {
handleNonMobileFallbackFunction = getDefaultHandleNonMobileFallBackFunction();
this.mobileActionFormGroup.patchValue({handleNonMobileFallbackFunction}, {emitEvent: false});
}
}
this.mobileActionTypeFormGroup = this.fb.group({});
if (type) {
@ -183,6 +206,10 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit
'processImageFunction',
this.fb.control(processImageFunction, [])
);
this.mobileActionTypeFormGroup.addControl(
'saveToGallery',
this.fb.control(action?.saveToGallery || false, [])
);
break;
case WidgetMobileActionType.mapDirection:
case WidgetMobileActionType.mapLocation:
@ -267,6 +294,10 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit
'handleProvisionSuccessFunction',
this.fb.control(handleProvisionSuccessFunction, [Validators.required])
);
this.mobileActionTypeFormGroup.addControl(
'provisionType',
this.fb.control(action?.provisionType || ProvisionType.auto, [])
);
}
}
this.mobileActionTypeFormGroup.valueChanges.pipe(
@ -276,5 +307,108 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit
});
}
getActionConfigs() {
const type = this.mobileActionFormGroup.get('type').value;
this.actionConfig = [];
switch (type) {
case this.mobileActionType.deviceProvision:
this.actionConfig.push({
title: 'widget-action.mobile.handle-provision-success-function',
formControlName: 'handleProvisionSuccessFunction',
functionName: 'handleProvisionSuccess',
functionArgs: ['deviceName', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']
});
break;
case this.mobileActionType.mapDirection:
case this.mobileActionType.mapLocation:
this.actionConfig.push({
title: 'widget-action.mobile.get-location-function',
formControlName: 'getLocationFunction',
functionName: 'getLocation',
functionArgs: ['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_get_location_fn'
});
this.actionConfig.push({
title: 'widget-action.mobile.process-launch-result-function',
formControlName: 'processLaunchResultFunction',
functionName: 'processLaunchResult',
functionArgs: ['launched', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_process_launch_result_fn'
});
break;
case this.mobileActionType.makePhoneCall:
this.actionConfig.push({
title: 'widget-action.mobile.get-phone-number-function',
formControlName: 'getPhoneNumberFunction',
functionName: 'getPhoneNumber',
functionArgs: ['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_get_phone_number_fn'
});
this.actionConfig.push({
title: 'widget-action.mobile.process-launch-result-function',
formControlName: 'processLaunchResultFunction',
functionName: 'processLaunchResult',
functionArgs: ['launched', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_process_launch_result_fn'
});
break;
case this.mobileActionType.takePhoto:
case this.mobileActionType.takePictureFromGallery:
case this.mobileActionType.takeScreenshot:
this.actionConfig.push({
title: 'widget-action.mobile.process-image-function',
formControlName: 'processImageFunction',
functionName: 'processImage',
functionArgs: ['imageUrl', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_process_image_fn'
});
break;
case this.mobileActionType.scanQrCode:
this.actionConfig.push({
title: 'widget-action.mobile.process-qr-code-function',
formControlName: 'processQrCodeFunction',
functionName: 'processQrCode',
functionArgs: ['code', 'format', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_process_qr_code_fn'
});
break;
case this.mobileActionType.getLocation:
this.actionConfig.push({
title: 'widget-action.mobile.process-location-function',
formControlName: 'processLocationFunction',
functionName: 'processLocation',
functionArgs: ['latitude', 'longitude', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_process_location_fn'
});
break;
}
}
getCommonActionConfigs() {
this.commonActionConfig = [
{
title: 'widget-action.mobile.handle-empty-result-function',
formControlName: 'handleEmptyResultFunction',
functionName: 'handleEmptyResult',
functionArgs: ['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_handle_empty_result_fn'
},
{
title: 'widget-action.mobile.handle-error-function',
formControlName: 'handleErrorFunction',
functionName: 'handleError',
functionArgs: ['error', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'],
helpId: 'widget/action/mobile_handle_error_fn'
},
{
title: 'widget-action.mobile.handle-non-mobile-fallback-function',
formControlName: 'handleNonMobileFallbackFunction',
functionName: 'handleNonMobileFallback',
functionArgs: ['$event', 'widgetContext'],
helpId: 'widget/action/mobile_handle_non_mobile_fallback_fn'
}
];
}
protected readonly WidgetActionType = WidgetActionType;
}

View File

@ -172,6 +172,14 @@ const handleErrorFunctionTemplate: TbFunction =
' }, 100);\n' +
'}\n';
const handleNonMobileFallbackFunctionTemplate: TbFunction =
'// Optional function body to handle non-mobile fallback \n' +
'showFallbackToast();\n' +
'\n' +
'function showFallbackToast(title, error) {\n' +
' widgetContext.showWarnToast(\'This action is only available in the mobile application.\');\n' +
'}\n';
const getLocationFunctionTemplate: TbFunction =
'// 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' +
@ -326,3 +334,5 @@ export const getDefaultHandleErrorFunction = (type: WidgetMobileActionType): TbF
}
return handleErrorFunctionTemplate.replace('--TITLE--', title);
};
export const getDefaultHandleNonMobileFallBackFunction = () => handleNonMobileFallbackFunctionTemplate;

View File

@ -38,6 +38,7 @@ import {
} from '@angular/core';
import { DashboardWidget } from '@home/models/dashboard-component.models';
import {
MobileImageResult,
Widget,
WidgetAction,
WidgetActionDescriptor,
@ -126,6 +127,7 @@ import { IModulesMap } from '@modules/common/modules-map.models';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
import { CompiledTbFunction, compileTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models';
import { HttpClient } from '@angular/common/http';
import { addDiagnosticChain } from '@angular/compiler-cli/src/ngtsc/diagnostics';
@Component({
selector: 'tb-widget',
@ -1222,12 +1224,16 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
switch (type) {
case WidgetMobileActionType.takePictureFromGallery:
case WidgetMobileActionType.takePhoto:
case WidgetMobileActionType.takeScreenshot:
argsObservable = of([mobileAction.saveToGallery]);
break;
case WidgetMobileActionType.scanQrCode:
case WidgetMobileActionType.getLocation:
case WidgetMobileActionType.takeScreenshot:
case WidgetMobileActionType.deviceProvision:
argsObservable = of([]);
break;
case WidgetMobileActionType.deviceProvision:
argsObservable = of([mobileAction.provisionType]);
break;
case WidgetMobileActionType.mapDirection:
case WidgetMobileActionType.mapLocation:
argsObservable = compileTbFunction(this.http, mobileAction.getLocationFunction, '$event', 'widgetContext', 'entityId',
@ -1297,6 +1303,10 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
case WidgetMobileActionType.takePhoto:
case WidgetMobileActionType.takeScreenshot:
const imageUrl = actionResult.imageUrl;
if (!additionalParams) {
additionalParams = {};
}
additionalParams.imageInfo = actionResult.imageInfo;
if (isNotEmptyTbFunction(mobileAction.processImageFunction)) {
compileTbFunction(this.http, mobileAction.processImageFunction, 'imageUrl', '$event', 'widgetContext', 'entityId',
'entityName', 'additionalParams', 'entityLabel').subscribe(
@ -1421,6 +1431,23 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
);
}
}
} else if (!this.mobileService.isMobileApp()) {
if (isNotEmptyTbFunction(mobileAction.handleNonMobileFallbackFunction)) {
compileTbFunction(this.http, mobileAction.handleNonMobileFallbackFunction, '$event', 'widgetContext',).subscribe(
{
next: (compiled) => {
try {
compiled.execute($event, this.widgetContext);
} catch (e) {
console.error(e);
}
},
error: (err) => {
console.error(err);
}
}
);
}
}
}
);

View File

@ -33,7 +33,7 @@ import { PageComponent } from '@shared/components/page.component';
import { AfterViewInit, DestroyRef, Directive, EventEmitter, inject, Inject, OnInit, Type } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { AbstractControl, UntypedFormGroup } from '@angular/forms';
import { AbstractControl, UntypedFormGroup, ValidatorFn } from '@angular/forms';
import { Observable } from 'rxjs';
import { Dashboard } from '@shared/models/dashboard.models';
import { IAliasController } from '@core/api/widget-api.models';
@ -51,6 +51,7 @@ import { TbFunction } from '@shared/models/js-function.models';
import { FormProperty, jsonFormSchemaToFormProperties } from '@shared/models/dynamic-form.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TbUnit } from '@shared/models/unit.models';
import { ImageResourceInfo } from '@shared/models/resource.models';
export enum widgetType {
timeseries = 'timeseries',
@ -622,6 +623,30 @@ export enum WidgetMobileActionType {
deviceProvision = 'deviceProvision',
}
export interface ActionConfig {
title: string,
formControlName: string,
functionName: string,
functionArgs: string[],
helpId?: string
}
export enum ProvisionType {
auto = 'auto',
wiFi = 'wiFi',
ble = 'ble',
softAp = 'softAp'
}
export const provisionTypeTranslationMap = new Map<ProvisionType, string>(
[
[ ProvisionType.auto, 'widget-action.mobile.auto' ],
[ ProvisionType.wiFi, 'widget-action.mobile.wi-fi' ],
[ ProvisionType.ble, 'widget-action.mobile.ble' ],
[ ProvisionType.softAp, 'widget-action.mobile.soft-ap' ],
]
);
export enum MapItemType {
marker = 'marker',
polygon = 'polygon',
@ -675,6 +700,7 @@ export interface MobileLaunchResult {
export interface MobileImageResult {
imageUrl: string;
imageInfo?: ImageResourceInfo;
}
export interface MobileQrCodeResult {
@ -706,10 +732,12 @@ export interface WidgetMobileActionResult<T extends MobileActionResult> {
export interface ProvisionSuccessDescriptor {
handleProvisionSuccessFunction: TbFunction;
provisionType?: string;
}
export interface ProcessImageDescriptor {
processImageFunction: TbFunction;
saveToGallery?: boolean;
}
export interface ProcessLaunchResultDescriptor {
@ -743,6 +771,7 @@ export interface WidgetMobileActionDescriptor extends WidgetMobileActionDescript
type: WidgetMobileActionType;
handleErrorFunction?: TbFunction;
handleEmptyResultFunction?: TbFunction;
handleNonMobileFallbackFunction?: TbFunction;
}
export interface CustomActionDescriptor {

View File

@ -6868,7 +6868,23 @@
"scan-qr-code": "Scan QR Code",
"make-phone-call": "Make phone call",
"get-location": "Get phone location",
"take-screenshot": "Take screenshot"
"take-screenshot": "Take screenshot",
"handle-provision-success-function": "Handle provision success function",
"get-location-function": "Get location function",
"process-launch-result-function": "Process launch result function",
"get-phone-number-function": "Get phone number function",
"process-image-function": "Process image function",
"process-qr-code-function": "Process QR code function",
"process-location-function": "Process location function",
"handle-empty-result-function": "Handle empty result function",
"handle-error-function": "Handle error function",
"handle-non-mobile-fallback-function": "Handle Non-Mobile fallback function",
"save-to-gallery": "Save to gallery",
"provision-type": "Provision type",
"auto": "Auto",
"wi-fi": "Wi-Fi",
"ble": "BLE",
"soft-ap": "Soft AP"
},
"custom-action-function": "Custom action function",
"custom-pretty-function": "Custom action (with HTML template) function",