Browse Source

Merge pull request #12171 from thingsboard/feature/js-module

Introduce support of reusable JavaScript modules for UI JavaScript functions.
pull/12172/head
Igor Kulikov 2 years ago
committed by GitHub
parent
commit
c6dda94a48
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 1
      application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
  3. 8
      application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
  4. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java
  5. 2
      dao/src/test/resources/nosql-test.properties
  6. 2
      dao/src/test/resources/sql-test.properties
  7. 673
      ui-ngx/src/app/core/api/entity-data-subscription.ts
  8. 8
      ui-ngx/src/app/core/api/entity-data.service.ts
  9. 7
      ui-ngx/src/app/core/http/resource.service.ts
  10. 13
      ui-ngx/src/app/core/services/menu.models.ts
  11. 45
      ui-ngx/src/app/core/services/utils.service.ts
  12. 45
      ui-ngx/src/app/core/utils.ts
  13. 1
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  14. 11
      ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts
  15. 2
      ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html
  16. 17
      ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts
  17. 13
      ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts
  18. 10
      ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html
  19. 293
      ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts
  20. 8
      ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html
  21. 301
      ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts
  22. 26
      ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts
  23. 31
      ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts
  24. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.html
  25. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html
  26. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/markdown-widget-settings.component.html
  27. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/qrcode-widget-settings.component.html
  28. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html
  29. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html
  30. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html
  31. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html
  32. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html
  33. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action.models.ts
  34. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html
  35. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html
  36. 1
      ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html
  37. 216
      ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts
  38. 12
      ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html
  39. 397
      ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts
  40. 303
      ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts
  41. 127
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  42. 2
      ui-ngx/src/app/modules/home/models/dashboard-component.models.ts
  43. 3
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  44. 39
      ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
  45. 20
      ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
  46. 171
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts
  47. 30
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html
  48. 42
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.ts
  49. 117
      ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html
  50. 176
      ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts
  51. 6
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html
  52. 10
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts
  53. 2
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts
  54. 33
      ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts
  55. 7
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html
  56. 115
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts
  57. 4
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts
  58. 10
      ui-ngx/src/app/shared/components/file-input.component.html
  59. 41
      ui-ngx/src/app/shared/components/file-input.component.scss
  60. 10
      ui-ngx/src/app/shared/components/file-input.component.ts
  61. 15
      ui-ngx/src/app/shared/components/help-markdown.component.ts
  62. 13
      ui-ngx/src/app/shared/components/help-popup.component.html
  63. 15
      ui-ngx/src/app/shared/components/help-popup.component.scss
  64. 56
      ui-ngx/src/app/shared/components/help-popup.component.ts
  65. 54
      ui-ngx/src/app/shared/components/js-func-module-row.component.html
  66. 23
      ui-ngx/src/app/shared/components/js-func-module-row.component.scss
  67. 190
      ui-ngx/src/app/shared/components/js-func-module-row.component.ts
  68. 66
      ui-ngx/src/app/shared/components/js-func-modules.component.html
  69. 74
      ui-ngx/src/app/shared/components/js-func-modules.component.scss
  70. 135
      ui-ngx/src/app/shared/components/js-func-modules.component.ts
  71. 14
      ui-ngx/src/app/shared/components/js-func.component.html
  72. 359
      ui-ngx/src/app/shared/components/js-func.component.ts
  73. 2
      ui-ngx/src/app/shared/components/json-form/json-form.component.ts
  74. 5
      ui-ngx/src/app/shared/components/popover.service.ts
  75. 8
      ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.html
  76. 22
      ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts
  77. 2
      ui-ngx/src/app/shared/models/ace/completion.models.ts
  78. 47
      ui-ngx/src/app/shared/models/error.models.ts
  79. 310
      ui-ngx/src/app/shared/models/js-function.models.ts
  80. 13
      ui-ngx/src/app/shared/models/resource.models.ts
  81. 11
      ui-ngx/src/app/shared/models/widget.models.ts
  82. 6
      ui-ngx/src/app/shared/shared.module.ts
  83. 45
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  84. 9
      ui-ngx/src/form.scss

8
application/src/main/data/upgrade/basic/schema_update.sql

@ -192,4 +192,10 @@ $$
END IF; END IF;
ALTER TABLE qr_code_settings DROP COLUMN IF EXISTS android_config, DROP COLUMN IF EXISTS ios_config; ALTER TABLE qr_code_settings DROP COLUMN IF EXISTS android_config, DROP COLUMN IF EXISTS ios_config;
END; END;
$$; $$;
-- UPDATE RESOURCE JS_MODULE SUB TYPE START
UPDATE resource SET resource_sub_type = 'EXTENSION' WHERE resource_type = 'JS_MODULE' AND resource_sub_type IS NULL;
-- UPDATE RESOURCE JS_MODULE SUB TYPE END

1
application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java

@ -125,6 +125,7 @@ public class ControllerConstants {
protected static final String RESOURCE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the resource title."; protected static final String RESOURCE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the resource title.";
protected static final String RESOURCE_TYPE = "A string value representing the resource type."; protected static final String RESOURCE_TYPE = "A string value representing the resource type.";
protected static final String RESOURCE_SUB_TYPE = "A string value representing the resource sub-type.";
protected static final String LWM2M_OBJECT_DESCRIPTION = "LwM2M Object is a object that includes information about the LwM2M model which can be used in transport configuration for the LwM2M device profile. "; protected static final String LWM2M_OBJECT_DESCRIPTION = "LwM2M Object is a object that includes information about the LwM2M model which can be used in transport configuration for the LwM2M device profile. ";

8
application/src/main/java/org/thingsboard/server/controller/TbResourceController.java

@ -38,6 +38,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfo;
@ -70,6 +71,7 @@ import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DE
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_INFO_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_INFO_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_SUB_TYPE;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TYPE; import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TYPE;
import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION;
@ -229,6 +231,8 @@ public class TbResourceController extends BaseController {
@RequestParam int page, @RequestParam int page,
@Parameter(description = RESOURCE_TYPE, schema = @Schema(allowableValues = {"LWM2M_MODEL", "JKS", "PKCS_12", "JS_MODULE"})) @Parameter(description = RESOURCE_TYPE, schema = @Schema(allowableValues = {"LWM2M_MODEL", "JKS", "PKCS_12", "JS_MODULE"}))
@RequestParam(required = false) String resourceType, @RequestParam(required = false) String resourceType,
@Parameter(description = RESOURCE_SUB_TYPE, schema = @Schema(allowableValues = {"EXTENSION", "MODULE"}))
@RequestParam(required = false) String resourceSubType,
@Parameter(description = RESOURCE_TEXT_SEARCH_DESCRIPTION) @Parameter(description = RESOURCE_TEXT_SEARCH_DESCRIPTION)
@RequestParam(required = false) String textSearch, @RequestParam(required = false) String textSearch,
@Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "title", "resourceType", "tenantId"})) @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "title", "resourceType", "tenantId"}))
@ -241,8 +245,12 @@ public class TbResourceController extends BaseController {
Set<ResourceType> resourceTypes = new HashSet<>(); Set<ResourceType> resourceTypes = new HashSet<>();
if (StringUtils.isNotEmpty(resourceType)) { if (StringUtils.isNotEmpty(resourceType)) {
resourceTypes.add(ResourceType.valueOf(resourceType)); resourceTypes.add(ResourceType.valueOf(resourceType));
if (StringUtils.isNotEmpty(resourceSubType)) {
filter.resourceSubTypes(Set.of(ResourceSubType.valueOf(resourceSubType)));
}
} else { } else {
Collections.addAll(resourceTypes, ResourceType.values()); Collections.addAll(resourceTypes, ResourceType.values());
resourceTypes.remove(ResourceType.JS_MODULE);
resourceTypes.remove(ResourceType.IMAGE); resourceTypes.remove(ResourceType.IMAGE);
resourceTypes.remove(ResourceType.DASHBOARD); resourceTypes.remove(ResourceType.DASHBOARD);
} }

4
common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java

@ -17,5 +17,7 @@ package org.thingsboard.server.common.data;
public enum ResourceSubType { public enum ResourceSubType {
IMAGE, IMAGE,
SCADA_SYMBOL SCADA_SYMBOL,
EXTENSION,
MODULE
} }

2
dao/src/test/resources/nosql-test.properties

@ -13,6 +13,6 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none spring.jpa.hibernate.ddl-auto=none
spring.datasource.username=postgres spring.datasource.username=postgres
spring.datasource.password=postgres spring.datasource.password=postgres
spring.datasource.url=jdbc:tc:postgresql:12.8:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb
spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.hikari.maximumPoolSize=16 spring.datasource.hikari.maximumPoolSize=16

2
dao/src/test/resources/sql-test.properties

@ -14,7 +14,7 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none spring.jpa.hibernate.ddl-auto=none
spring.datasource.username=postgres spring.datasource.username=postgres
spring.datasource.password=postgres spring.datasource.password=postgres
spring.datasource.url=jdbc:tc:postgresql:12.19:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb
spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.hikari.maximumPoolSize=16 spring.datasource.hikari.maximumPoolSize=16

673
ui-ngx/src/app/core/api/entity-data-subscription.ts

@ -19,14 +19,16 @@ import {
DataEntry, DataEntry,
DataSet, DataSet,
DataSetHolder, DataSetHolder,
DatasourceType, IndexedData, DatasourceType,
IndexedData,
widgetType widgetType
} from '@shared/models/widget.models'; } from '@shared/models/widget.models';
import { import {
AggregationType, AggregationType,
ComparisonDuration, ComparisonDuration,
createTimewindowForComparison, createTimewindowForComparison,
getCurrentTime, IntervalMath, getCurrentTime,
IntervalMath,
SubscriptionTimewindow SubscriptionTimewindow
} from '@shared/models/time/time.models'; } from '@shared/models/time/time.models';
import { import {
@ -51,7 +53,8 @@ import {
IndexedSubscriptionData, IndexedSubscriptionData,
IntervalType, IntervalType,
NOT_SUPPORTED, NOT_SUPPORTED,
SubscriptionData, SubscriptionDataEntry, SubscriptionData,
SubscriptionDataEntry,
TelemetrySubscriber TelemetrySubscriber
} from '@shared/models/telemetry/telemetry.models'; } from '@shared/models/telemetry/telemetry.models';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
@ -61,9 +64,16 @@ import { PageData } from '@shared/models/page/page-data';
import { DataAggregator, onAggregatedData } from '@core/api/data-aggregator'; import { DataAggregator, onAggregatedData } from '@core/api/data-aggregator';
import { NULL_UUID } from '@shared/models/id/has-uuid'; import { NULL_UUID } from '@shared/models/id/has-uuid';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { Observable, of, ReplaySubject, Subject } from 'rxjs'; import { firstValueFrom, from, Observable, of, ReplaySubject, Subject, tap } from 'rxjs';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service'; import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service';
import {
CompiledTbFunction,
compileTbFunction,
isNotEmptyTbFunction,
TbFunction
} from '@shared/models/js-function.models';
import { HttpClient } from '@angular/common/http';
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;
declare type DataKeyFunction = (time: number, prevValue: any) => any; declare type DataKeyFunction = (time: number, prevValue: any) => any;
@ -79,10 +89,10 @@ export interface SubscriptionDataKey {
timeForComparison?: ComparisonDuration; timeForComparison?: ComparisonDuration;
comparisonCustomIntervalValue?: number; comparisonCustomIntervalValue?: number;
comparisonResultType?: ComparisonResultType; comparisonResultType?: ComparisonResultType;
funcBody: string; funcBody: TbFunction;
func?: DataKeyFunction; func?: CompiledTbFunction<DataKeyFunction>;
postFuncBody: string; postFuncBody: TbFunction;
postFunc?: DataKeyPostFunction; postFunc?: CompiledTbFunction<DataKeyPostFunction>;
index?: number; index?: number;
listIndex?: number; listIndex?: number;
key?: string; key?: string;
@ -109,8 +119,8 @@ export class EntityDataSubscription {
constructor(private listener: EntityDataListener, constructor(private listener: EntityDataListener,
private telemetryService: TelemetryWebsocketService, private telemetryService: TelemetryWebsocketService,
private utils: UtilsService) { private utils: UtilsService,
this.initializeSubscription(); private http: HttpClient) {
} }
private entityDataSubscriptionOptions = this.listener.subscriptionOptions; private entityDataSubscriptionOptions = this.listener.subscriptionOptions;
@ -193,19 +203,20 @@ export class EntityDataSubscription {
return [[timestamp, value]]; return [[timestamp, value]];
} }
private initializeSubscription() { private async initializeSubscription() {
for (let i = 0; i < this.entityDataSubscriptionOptions.dataKeys.length; i++) { for (let i = 0; i < this.entityDataSubscriptionOptions.dataKeys.length; i++) {
const dataKey = deepClone(this.entityDataSubscriptionOptions.dataKeys[i]); const dataKey = deepClone(this.entityDataSubscriptionOptions.dataKeys[i]);
this.dataKeysList.push(dataKey); this.dataKeysList.push(dataKey);
dataKey.index = i; dataKey.index = i;
if (this.datasourceType === DatasourceType.function) { if (this.datasourceType === DatasourceType.function) {
if (!dataKey.func) { if (!dataKey.func) {
dataKey.func = new Function('time', 'prevValue', dataKey.funcBody) as DataKeyFunction; dataKey.func = await firstValueFrom(compileTbFunction(this.http, dataKey.funcBody, 'time', 'prevValue'));
} }
} else { } else {
if (dataKey.postFuncBody && !dataKey.postFunc) { if (isNotEmptyTbFunction(dataKey.postFuncBody) && !dataKey.postFunc) {
dataKey.postFunc = new Function('time', 'value', 'prevValue', 'timePrev', 'prevOrigValue', try {
dataKey.postFuncBody) as DataKeyPostFunction; dataKey.postFunc = await firstValueFrom(compileTbFunction(this.http, dataKey.postFuncBody, 'time', 'value', 'prevValue', 'timePrev', 'prevOrigValue'));
} catch (e) {}
} }
} }
let key: string; let key: string;
@ -266,348 +277,352 @@ export class EntityDataSubscription {
public subscribe(): Observable<EntityDataLoadResult> { public subscribe(): Observable<EntityDataLoadResult> {
this.entityDataResolveSubject = new ReplaySubject(1); this.entityDataResolveSubject = new ReplaySubject(1);
if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { from(this.initializeSubscription()).pipe(
this.started = true; tap(() => {
this.dataResolved = true; if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) {
this.prepareSubscriptionTimewindow(); this.started = true;
} this.dataResolved = true;
if (this.datasourceType === DatasourceType.entity) { this.prepareSubscriptionTimewindow();
const entityFields: Array<EntityKey> = }
this.dataKeysList.filter(dataKey => dataKey.type === DataKeyType.entityField).map( if (this.datasourceType === DatasourceType.entity) {
dataKey => ({ type: EntityKeyType.ENTITY_FIELD, key: dataKey.name }) const entityFields: Array<EntityKey> =
); this.dataKeysList.filter(dataKey => dataKey.type === DataKeyType.entityField).map(
if (!entityFields.find(key => key.key === 'name')) { dataKey => ({ type: EntityKeyType.ENTITY_FIELD, key: dataKey.name })
entityFields.push({ );
type: EntityKeyType.ENTITY_FIELD, if (!entityFields.find(key => key.key === 'name')) {
key: 'name' entityFields.push({
}); type: EntityKeyType.ENTITY_FIELD,
} key: 'name'
if (!entityFields.find(key => key.key === 'label')) { });
entityFields.push({ }
type: EntityKeyType.ENTITY_FIELD, if (!entityFields.find(key => key.key === 'label')) {
key: 'label' entityFields.push({
}); type: EntityKeyType.ENTITY_FIELD,
} key: 'label'
if (!entityFields.find(key => key.key === 'additionalInfo')) { });
entityFields.push({ }
type: EntityKeyType.ENTITY_FIELD, if (!entityFields.find(key => key.key === 'additionalInfo')) {
key: 'additionalInfo' entityFields.push({
}); type: EntityKeyType.ENTITY_FIELD,
} key: 'additionalInfo'
});
}
this.attrFields = this.dataKeysList.filter(dataKey => dataKey.type === DataKeyType.attribute).map( this.attrFields = this.dataKeysList.filter(dataKey => dataKey.type === DataKeyType.attribute).map(
dataKey => ({ type: EntityKeyType.ATTRIBUTE, key: dataKey.name }) dataKey => ({ type: EntityKeyType.ATTRIBUTE, key: dataKey.name })
); );
this.tsFields = this.dataKeysList. this.tsFields = this.dataKeysList.
filter(dataKey => dataKey.type === DataKeyType.timeseries && filter(dataKey => dataKey.type === DataKeyType.timeseries &&
(!dataKey.aggregationType || dataKey.aggregationType === AggregationType.NONE) && !dataKey.latest).map( (!dataKey.aggregationType || dataKey.aggregationType === AggregationType.NONE) && !dataKey.latest).map(
dataKey => ({ type: EntityKeyType.TIME_SERIES, key: dataKey.name }) dataKey => ({ type: EntityKeyType.TIME_SERIES, key: dataKey.name })
); );
if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) {
const latestTsFields = this.dataKeysList. const latestTsFields = this.dataKeysList.
filter(dataKey => dataKey.type === DataKeyType.timeseries && dataKey.latest && filter(dataKey => dataKey.type === DataKeyType.timeseries && dataKey.latest &&
(!dataKey.aggregationType || dataKey.aggregationType === AggregationType.NONE)).map( (!dataKey.aggregationType || dataKey.aggregationType === AggregationType.NONE)).map(
dataKey => ({ type: EntityKeyType.TIME_SERIES, key: dataKey.name }) dataKey => ({ type: EntityKeyType.TIME_SERIES, key: dataKey.name })
); );
this.latestValues = this.attrFields.concat(latestTsFields); this.latestValues = this.attrFields.concat(latestTsFields);
} else { } else {
this.latestValues = this.attrFields.concat(this.tsFields); this.latestValues = this.attrFields.concat(this.tsFields);
} }
this.aggTsValues = this.dataKeysList. this.aggTsValues = this.dataKeysList.
filter(dataKey => dataKey.type === DataKeyType.timeseries && filter(dataKey => dataKey.type === DataKeyType.timeseries &&
dataKey.aggregationType && dataKey.aggregationType !== AggregationType.NONE && !dataKey.comparisonEnabled).map( dataKey.aggregationType && dataKey.aggregationType !== AggregationType.NONE && !dataKey.comparisonEnabled).map(
dataKey => ({ id: dataKey.index, key: dataKey.name, agg: dataKey.aggregationType }) dataKey => ({ id: dataKey.index, key: dataKey.name, agg: dataKey.aggregationType })
); );
this.aggTsComparisonValues = this.dataKeysList. this.aggTsComparisonValues = this.dataKeysList.
filter(dataKey => dataKey.type === DataKeyType.timeseries && filter(dataKey => dataKey.type === DataKeyType.timeseries &&
dataKey.aggregationType && dataKey.aggregationType !== AggregationType.NONE && dataKey.comparisonEnabled).map( dataKey.aggregationType && dataKey.aggregationType !== AggregationType.NONE && dataKey.comparisonEnabled).map(
dataKey => ({ id: dataKey.index, key: dataKey.name, agg: dataKey.aggregationType, dataKey => ({ id: dataKey.index, key: dataKey.name, agg: dataKey.aggregationType,
previousValueOnly: dataKey.comparisonResultType === ComparisonResultType.PREVIOUS_VALUE }) previousValueOnly: dataKey.comparisonResultType === ComparisonResultType.PREVIOUS_VALUE })
); );
this.subscriber = new TelemetrySubscriber(this.telemetryService); this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.dataCommand = new EntityDataCmd(); this.dataCommand = new EntityDataCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters; let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) { if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) { if (keyFilters) {
keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters); keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters);
} else { } else {
keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters; keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters;
} }
} }
this.dataCommand.query = { this.dataCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter, entityFilter: this.entityDataSubscriptionOptions.entityFilter,
pageLink: this.entityDataSubscriptionOptions.pageLink, pageLink: this.entityDataSubscriptionOptions.pageLink,
keyFilters, keyFilters,
entityFields, entityFields,
latestValues: this.latestValues latestValues: this.latestValues
}; };
if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) {
this.prepareSubscriptionCommands(this.dataCommand); this.prepareSubscriptionCommands(this.dataCommand);
if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) {
this.subscriber.setTsOffset(this.subsTw.tsOffset); this.subscriber.setTsOffset(this.subsTw.tsOffset);
} else { } else {
this.subscriber.setTsOffset(this.latestTsOffset); this.subscriber.setTsOffset(this.latestTsOffset);
} }
} }
this.subscriber.subscriptionCommands.push(this.dataCommand); this.subscriber.subscriptionCommands.push(this.dataCommand);
this.subscriber.entityData$.subscribe( this.subscriber.entityData$.subscribe(
(entityDataUpdate) => { (entityDataUpdate) => {
if (entityDataUpdate.data) { if (entityDataUpdate.data) {
this.onPageData(entityDataUpdate.data); this.onPageData(entityDataUpdate.data);
if (this.prematureUpdates) { if (this.prematureUpdates) {
for (const update of this.prematureUpdates) { for (const update of this.prematureUpdates) {
this.onDataUpdate(update); this.onDataUpdate(update);
}
this.prematureUpdates = null;
}
} else if (entityDataUpdate.update) {
if (!this.pageData) {
if (!this.prematureUpdates) {
this.prematureUpdates = [];
}
this.prematureUpdates.push(entityDataUpdate.update);
} else {
this.onDataUpdate(entityDataUpdate.update);
}
} }
this.prematureUpdates = null;
} }
} else if (entityDataUpdate.update) { );
if (!this.pageData) {
if (!this.prematureUpdates) { this.subscriber.reconnect$.subscribe(() => {
this.prematureUpdates = []; if (this.started) {
const targetCommand = this.entityDataSubscriptionOptions.isPaginatedDataSubscription ? this.dataCommand : this.subsCommand;
if (!this.history && (this.entityDataSubscriptionOptions.type === widgetType.timeseries && this.tsFields.length ||
this.aggTsValues.length > 0 && !this.isFloatingTimewindow)) {
const newSubsTw = this.listener.updateRealtimeSubscription();
this.subsTw = newSubsTw;
if (this.entityDataSubscriptionOptions.type === widgetType.timeseries && this.tsFields.length) {
targetCommand.tsCmd.startTs = this.subsTw.startTs;
targetCommand.tsCmd.timeWindow = this.subsTw.aggregation.timeWindow;
if (typeof this.subsTw.aggregation.interval === 'number') {
targetCommand.tsCmd.interval = this.subsTw.aggregation.interval;
targetCommand.tsCmd.intervalType = IntervalType.MILLISECONDS;
} else {
targetCommand.tsCmd.intervalType = this.subsTw.aggregation.interval;
}
targetCommand.tsCmd.timeZoneId = this.subsTw.timezone;
targetCommand.tsCmd.limit = this.subsTw.aggregation.limit;
targetCommand.tsCmd.agg = this.subsTw.aggregation.type;
targetCommand.tsCmd.fetchLatestPreviousPoint = this.subsTw.aggregation.stateData;
this.dataAggregators.forEach((dataAggregator) => {
dataAggregator.reset(newSubsTw);
});
}
if (this.aggTsValues.length > 0 && !this.isFloatingTimewindow) {
targetCommand.aggTsCmd.startTs = this.subsTw.startTs;
targetCommand.aggTsCmd.timeWindow = this.subsTw.aggregation.timeWindow;
this.tsLatestDataAggregators.forEach((dataAggregator) => {
dataAggregator.reset(newSubsTw);
});
}
} }
this.prematureUpdates.push(entityDataUpdate.update); if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) {
this.subscriber.setTsOffset(this.subsTw.tsOffset);
} else {
this.subscriber.setTsOffset(this.latestTsOffset);
}
targetCommand.query = this.dataCommand.query;
this.subscriber.subscriptionCommands = [targetCommand];
} else { } else {
this.onDataUpdate(entityDataUpdate.update); this.subscriber.subscriptionCommands = [this.dataCommand];
} }
});
this.subscriber.subscribe();
} else if (this.datasourceType === DatasourceType.function) {
let tsOffset = 0;
if (this.entityDataSubscriptionOptions.type === widgetType.latest) {
tsOffset = this.entityDataSubscriptionOptions.latestTsOffset;
} else if (this.entityDataSubscriptionOptions.subscriptionTimewindow) {
tsOffset = this.entityDataSubscriptionOptions.subscriptionTimewindow.tsOffset;
} }
}
);
this.subscriber.reconnect$.subscribe(() => { const entityData: EntityData = {
if (this.started) { entityId: {
const targetCommand = this.entityDataSubscriptionOptions.isPaginatedDataSubscription ? this.dataCommand : this.subsCommand; id: NULL_UUID,
if (!this.history && (this.entityDataSubscriptionOptions.type === widgetType.timeseries && this.tsFields.length || entityType: EntityType.DEVICE
this.aggTsValues.length > 0 && !this.isFloatingTimewindow)) { },
const newSubsTw = this.listener.updateRealtimeSubscription(); timeseries: {},
this.subsTw = newSubsTw; latest: {}
if (this.entityDataSubscriptionOptions.type === widgetType.timeseries && this.tsFields.length) { };
targetCommand.tsCmd.startTs = this.subsTw.startTs; const name = DatasourceType.function;
targetCommand.tsCmd.timeWindow = this.subsTw.aggregation.timeWindow; entityData.latest[EntityKeyType.ENTITY_FIELD] = {
if (typeof this.subsTw.aggregation.interval === 'number') { name: {ts: Date.now() + tsOffset, value: name}
targetCommand.tsCmd.interval = this.subsTw.aggregation.interval; };
targetCommand.tsCmd.intervalType = IntervalType.MILLISECONDS; const pageData: PageData<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
} else if (this.datasourceType === DatasourceType.entityCount) {
this.latestTsOffset = this.entityDataSubscriptionOptions.latestTsOffset;
this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.subscriber.setTsOffset(this.latestTsOffset);
this.countCommand = new EntityCountCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) {
keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters);
} else {
keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters;
}
}
this.countCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters
};
this.subscriber.subscriptionCommands.push(this.countCommand);
const entityId: EntityId = {
id: NULL_UUID,
entityType: null
};
const countKey = this.dataKeysList[0];
let dataReceived = false;
this.subscriber.entityCount$.subscribe(
(entityCountUpdate) => {
if (!dataReceived) {
const entityData: EntityData = {
entityId,
latest: {
[EntityKeyType.ENTITY_FIELD]: {
name: {
ts: Date.now() + this.latestTsOffset,
value: DatasourceType.entityCount
}
},
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: entityCountUpdate.count + ''
}
}
},
timeseries: {}
};
const pageData: PageData<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
dataReceived = true;
} else { } else {
targetCommand.tsCmd.intervalType = this.subsTw.aggregation.interval; const update: EntityData[] = [{
entityId,
latest: {
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: entityCountUpdate.count + ''
}
}
},
timeseries: {}
}];
this.onDataUpdate(update);
} }
targetCommand.tsCmd.timeZoneId = this.subsTw.timezone;
targetCommand.tsCmd.limit = this.subsTw.aggregation.limit;
targetCommand.tsCmd.agg = this.subsTw.aggregation.type;
targetCommand.tsCmd.fetchLatestPreviousPoint = this.subsTw.aggregation.stateData;
this.dataAggregators.forEach((dataAggregator) => {
dataAggregator.reset(newSubsTw);
});
} }
if (this.aggTsValues.length > 0 && !this.isFloatingTimewindow) { );
targetCommand.aggTsCmd.startTs = this.subsTw.startTs; this.subscriber.subscribe();
targetCommand.aggTsCmd.timeWindow = this.subsTw.aggregation.timeWindow; } else if (this.datasourceType === DatasourceType.alarmCount) {
this.tsLatestDataAggregators.forEach((dataAggregator) => { this.latestTsOffset = this.entityDataSubscriptionOptions.latestTsOffset;
dataAggregator.reset(newSubsTw); this.subscriber = new TelemetrySubscriber(this.telemetryService);
}); this.subscriber.setTsOffset(this.latestTsOffset);
this.alarmCountCommand = new AlarmCountCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) {
keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters);
} else {
keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters;
} }
} }
if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { this.alarmCountCommand.query = {
this.subscriber.setTsOffset(this.subsTw.tsOffset); entityFilter: this.entityDataSubscriptionOptions.entityFilter,
} else { keyFilters
this.subscriber.setTsOffset(this.latestTsOffset); };
} if (this.entityDataSubscriptionOptions.alarmFilter) {
targetCommand.query = this.dataCommand.query; this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter};
this.subscriber.subscriptionCommands = [targetCommand];
} else {
this.subscriber.subscriptionCommands = [this.dataCommand];
}
});
this.subscriber.subscribe();
} else if (this.datasourceType === DatasourceType.function) {
let tsOffset = 0;
if (this.entityDataSubscriptionOptions.type === widgetType.latest) {
tsOffset = this.entityDataSubscriptionOptions.latestTsOffset;
} else if (this.entityDataSubscriptionOptions.subscriptionTimewindow) {
tsOffset = this.entityDataSubscriptionOptions.subscriptionTimewindow.tsOffset;
}
const entityData: EntityData = {
entityId: {
id: NULL_UUID,
entityType: EntityType.DEVICE
},
timeseries: {},
latest: {}
};
const name = DatasourceType.function;
entityData.latest[EntityKeyType.ENTITY_FIELD] = {
name: {ts: Date.now() + tsOffset, value: name}
};
const pageData: PageData<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
} else if (this.datasourceType === DatasourceType.entityCount) {
this.latestTsOffset = this.entityDataSubscriptionOptions.latestTsOffset;
this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.subscriber.setTsOffset(this.latestTsOffset);
this.countCommand = new EntityCountCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) {
keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters);
} else {
keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters;
}
}
this.countCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters
};
this.subscriber.subscriptionCommands.push(this.countCommand);
const entityId: EntityId = {
id: NULL_UUID,
entityType: null
};
const countKey = this.dataKeysList[0];
let dataReceived = false;
this.subscriber.entityCount$.subscribe(
(entityCountUpdate) => {
if (!dataReceived) {
const entityData: EntityData = {
entityId,
latest: {
[EntityKeyType.ENTITY_FIELD]: {
name: {
ts: Date.now() + this.latestTsOffset,
value: DatasourceType.entityCount
}
},
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: entityCountUpdate.count + ''
}
}
},
timeseries: {}
};
const pageData: PageData<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
dataReceived = true;
} else {
const update: EntityData[] = [{
entityId,
latest: {
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: entityCountUpdate.count + ''
}
}
},
timeseries: {}
}];
this.onDataUpdate(update);
} }
} this.subscriber.subscriptionCommands.push(this.alarmCountCommand);
);
this.subscriber.subscribe();
} else if (this.datasourceType === DatasourceType.alarmCount) {
this.latestTsOffset = this.entityDataSubscriptionOptions.latestTsOffset;
this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.subscriber.setTsOffset(this.latestTsOffset);
this.alarmCountCommand = new AlarmCountCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) {
keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters);
} else {
keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters;
}
}
this.alarmCountCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters
};
if (this.entityDataSubscriptionOptions.alarmFilter) {
this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter};
}
this.subscriber.subscriptionCommands.push(this.alarmCountCommand);
const entityId: EntityId = {
id: NULL_UUID,
entityType: null
};
const countKey = this.dataKeysList[0];
let dataReceived = false; const entityId: EntityId = {
id: NULL_UUID,
entityType: null
};
this.subscriber.alarmCount$.subscribe( const countKey = this.dataKeysList[0];
(alarmCountUpdate) => {
if (!dataReceived) { let dataReceived = false;
const entityData: EntityData = {
entityId, this.subscriber.alarmCount$.subscribe(
latest: { (alarmCountUpdate) => {
[EntityKeyType.ENTITY_FIELD]: { if (!dataReceived) {
name: { const entityData: EntityData = {
ts: Date.now() + this.latestTsOffset, entityId,
value: DatasourceType.alarmCount latest: {
} [EntityKeyType.ENTITY_FIELD]: {
}, name: {
[EntityKeyType.COUNT]: { ts: Date.now() + this.latestTsOffset,
[countKey.name]: { value: DatasourceType.alarmCount
ts: Date.now() + this.latestTsOffset, }
value: alarmCountUpdate.count + '' },
} [EntityKeyType.COUNT]: {
} [countKey.name]: {
}, ts: Date.now() + this.latestTsOffset,
timeseries: {} value: alarmCountUpdate.count + ''
}; }
const pageData: PageData<EntityData> = { }
data: [entityData], },
hasNext: false, timeseries: {}
totalElements: 1, };
totalPages: 1 const pageData: PageData<EntityData> = {
}; data: [entityData],
this.onPageData(pageData); hasNext: false,
dataReceived = true; totalElements: 1,
} else { totalPages: 1
const update: EntityData[] = [{ };
entityId, this.onPageData(pageData);
latest: { dataReceived = true;
[EntityKeyType.COUNT]: { } else {
[countKey.name]: { const update: EntityData[] = [{
ts: Date.now() + this.latestTsOffset, entityId,
value: alarmCountUpdate.count + '' latest: {
} [EntityKeyType.COUNT]: {
} [countKey.name]: {
}, ts: Date.now() + this.latestTsOffset,
timeseries: {} value: alarmCountUpdate.count + ''
}]; }
this.onDataUpdate(update); }
} },
timeseries: {}
}];
this.onDataUpdate(update);
}
}
);
this.subscriber.subscribe();
} }
); })
this.subscriber.subscribe(); ).subscribe();
}
if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) {
return of(null); return of(null);
} else { } else {
@ -1104,7 +1119,7 @@ export class EntityDataSubscription {
this.datasourceOrigData[dataIndex][datasourceKey].data.push([series[0], series[1], series[2]]); this.datasourceOrigData[dataIndex][datasourceKey].data.push([series[0], series[1], series[2]]);
let value = EntityDataSubscription.convertValue(series[1]); let value = EntityDataSubscription.convertValue(series[1]);
if (dataKey.postFunc) { if (dataKey.postFunc) {
value = dataKey.postFunc(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]); value = dataKey.postFunc.execute(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]);
} }
prevOrigSeries = [series[0], series[1], series[2]]; prevOrigSeries = [series[0], series[1], series[2]];
series = [series[0], value, series[2]]; series = [series[0], value, series[2]];
@ -1119,7 +1134,7 @@ export class EntityDataSubscription {
this.datasourceOrigData[dataIndex][datasourceKey].data.push([series[0], series[1], series[2]]); this.datasourceOrigData[dataIndex][datasourceKey].data.push([series[0], series[1], series[2]]);
let value = EntityDataSubscription.convertValue(series[1]); let value = EntityDataSubscription.convertValue(series[1]);
if (dataKey.postFunc) { if (dataKey.postFunc) {
value = dataKey.postFunc(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]); value = dataKey.postFunc.execute(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]);
} }
series = [time, value, series[2]]; series = [time, value, series[2]];
data.push([series[0], series[1], series[2]]); data.push([series[0], series[1], series[2]]);
@ -1196,7 +1211,7 @@ export class EntityDataSubscription {
prevSeries = [0, 0]; prevSeries = [0, 0];
} }
for (let time = startTime; time <= endTime && (this.timeseriesTimer || this.history); time += this.frequency) { for (let time = startTime; time <= endTime && (this.timeseriesTimer || this.history); time += this.frequency) {
const value = dataKey.func(time, prevSeries[1]); const value = dataKey.func.execute(time, prevSeries[1]);
const series: [number, any] = [time, value]; const series: [number, any] = [time, value];
data.push(series); data.push(series);
prevSeries = series; prevSeries = series;
@ -1217,7 +1232,7 @@ export class EntityDataSubscription {
prevSeries = [0, 0]; prevSeries = [0, 0];
} }
const time = Date.now() + this.latestTsOffset; const time = Date.now() + this.latestTsOffset;
const value = dataKey.func(time, prevSeries[1]); const value = dataKey.func.execute(time, prevSeries[1]);
const series: [number, any] = [time, value]; const series: [number, any] = [time, value];
this.datasourceData[0][datasourceKey].data = [series]; this.datasourceData[0][datasourceKey].data = [series];
this.listener.dataUpdated(this.datasourceData[0][datasourceKey], this.listener.dataUpdated(this.datasourceData[0][datasourceKey],

8
ui-ngx/src/app/core/api/entity-data.service.ts

@ -28,6 +28,7 @@ import {
SubscriptionDataKey SubscriptionDataKey
} from '@core/api/entity-data-subscription'; } from '@core/api/entity-data-subscription';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
export interface EntityDataListener { export interface EntityDataListener {
subscriptionType: widgetType; subscriptionType: widgetType;
@ -62,7 +63,8 @@ export interface EntityDataLoadResult {
export class EntityDataService { export class EntityDataService {
constructor(private telemetryService: TelemetryWebsocketService, constructor(private telemetryService: TelemetryWebsocketService,
private utils: UtilsService) {} private utils: UtilsService,
private http: HttpClient) {}
private static isUnresolvedDatasource(datasource: Datasource, pageLink: EntityDataPageLink): boolean { private static isUnresolvedDatasource(datasource: Datasource, pageLink: EntityDataPageLink): boolean {
if (datasource.type === DatasourceType.entity) { if (datasource.type === DatasourceType.entity) {
@ -103,7 +105,7 @@ export class EntityDataService {
if (EntityDataService.isUnresolvedDatasource(datasource, datasource.pageLink)) { if (EntityDataService.isUnresolvedDatasource(datasource, datasource.pageLink)) {
return of(null); return of(null);
} }
listener.subscription = new EntityDataSubscription(listener, this.telemetryService, this.utils); listener.subscription = new EntityDataSubscription(listener, this.telemetryService, this.utils, this.http);
return listener.subscription.subscribe(); return listener.subscription.subscribe();
} }
@ -137,7 +139,7 @@ export class EntityDataService {
listener.configDatasourceIndex, listener.subscriptionOptions.pageLink); listener.configDatasourceIndex, listener.subscriptionOptions.pageLink);
return of(null); return of(null);
} }
listener.subscription = new EntityDataSubscription(listener, this.telemetryService, this.utils); listener.subscription = new EntityDataSubscription(listener, this.telemetryService, this.utils, this.http);
if (listener.useTimewindow) { if (listener.useTimewindow) {
listener.subscriptionOptions.subscriptionTimewindow = deepClone(listener.subscriptionTimewindow); listener.subscriptionOptions.subscriptionTimewindow = deepClone(listener.subscriptionTimewindow);
} }

7
ui-ngx/src/app/core/http/resource.service.ts

@ -20,7 +20,7 @@ import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { forkJoin, Observable, of } from 'rxjs'; import { forkJoin, Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { Resource, ResourceInfo, ResourceType, TBResourceScope } from '@shared/models/resource.models'; import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope } from '@shared/models/resource.models';
import { catchError, mergeMap } from 'rxjs/operators'; import { catchError, mergeMap } from 'rxjs/operators';
import { isNotEmptyStr } from '@core/utils'; import { isNotEmptyStr } from '@core/utils';
import { ResourcesService } from '@core/services/resources.service'; import { ResourcesService } from '@core/services/resources.service';
@ -36,11 +36,14 @@ export class ResourceService {
} }
public getResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable<PageData<ResourceInfo>> { public getResources(pageLink: PageLink, resourceType?: ResourceType, resourceSubType?: ResourceSubType, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
let url = `/api/resource${pageLink.toQuery()}`; let url = `/api/resource${pageLink.toQuery()}`;
if (isNotEmptyStr(resourceType)) { if (isNotEmptyStr(resourceType)) {
url += `&resourceType=${resourceType}`; url += `&resourceType=${resourceType}`;
} }
if (isNotEmptyStr(resourceSubType)) {
url += `&resourceSubType=${resourceSubType}`;
}
return this.http.get<PageData<ResourceInfo>>(url, defaultHttpOptionsFromConfig(config)); return this.http.get<PageData<ResourceInfo>>(url, defaultHttpOptionsFromConfig(config));
} }

13
ui-ngx/src/app/core/services/menu.models.ts

@ -59,6 +59,7 @@ export enum MenuId {
images = 'images', images = 'images',
scada_symbols = 'scada_symbols', scada_symbols = 'scada_symbols',
resources_library = 'resources_library', resources_library = 'resources_library',
javascript_library = 'javascript_library',
notifications_center = 'notifications_center', notifications_center = 'notifications_center',
notification_inbox = 'notification_inbox', notification_inbox = 'notification_inbox',
notification_sent = 'notification_sent', notification_sent = 'notification_sent',
@ -209,6 +210,16 @@ export const menuSectionMap = new Map<MenuId, MenuSection>([
icon: 'mdi:rhombus-split' icon: 'mdi:rhombus-split'
} }
], ],
[
MenuId.javascript_library,
{
id: MenuId.javascript_library,
name: 'javascript.javascript-library',
type: 'link',
path: '/resources/javascript-library',
icon: 'mdi:language-javascript'
}
],
[ [
MenuId.notifications_center, MenuId.notifications_center,
{ {
@ -707,6 +718,7 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
}, },
{id: MenuId.images}, {id: MenuId.images},
{id: MenuId.scada_symbols}, {id: MenuId.scada_symbols},
{id: MenuId.javascript_library},
{id: MenuId.resources_library} {id: MenuId.resources_library}
] ]
}, },
@ -803,6 +815,7 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
}, },
{id: MenuId.images}, {id: MenuId.images},
{id: MenuId.scada_symbols}, {id: MenuId.scada_symbols},
{id: MenuId.javascript_library},
{id: MenuId.resources_library} {id: MenuId.resources_library}
] ]
}, },

45
ui-ngx/src/app/core/services/utils.service.ts

@ -14,12 +14,9 @@
/// limitations under the License. /// limitations under the License.
/// ///
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path="../../../../src/typings/rawloader.typings.d.ts" />
import { Inject, Injectable, NgZone, Renderer2 } from '@angular/core'; import { Inject, Injectable, NgZone, Renderer2 } from '@angular/core';
import { WINDOW } from '@core/services/window.service'; import { WINDOW } from '@core/services/window.service';
import { ExceptionData } from '@app/shared/models/error.models'; import { ExceptionData, parseException } from '@app/shared/models/error.models';
import { import {
base64toObj, base64toObj,
base64toString, base64toString,
@ -53,6 +50,7 @@ import { EntityId } from '@shared/models/id/entity-id';
import { DatePipe, DOCUMENT } from '@angular/common'; import { DatePipe, DOCUMENT } from '@angular/common';
import { entityTypeTranslations } from '@shared/models/entity-type.models'; import { entityTypeTranslations } from '@shared/models/entity-type.models';
import cssjs from '@core/css/css'; import cssjs from '@core/css/css';
import { isNotEmptyTbFunction } from '@shared/models/js-function.models';
const i18nRegExp = new RegExp(`{${i18nPrefix}:[^{}]+}`, 'g'); const i18nRegExp = new RegExp(`{${i18nPrefix}:[^{}]+}`, 'g');
@ -214,42 +212,7 @@ export class UtilsService {
} }
public parseException(exception: any, lineOffset?: number): ExceptionData { public parseException(exception: any, lineOffset?: number): ExceptionData {
const data: ExceptionData = {}; return parseException(exception, lineOffset);
if (exception) {
if (typeof exception === 'string') {
data.message = exception;
} else if (exception instanceof String) {
data.message = exception.toString();
} else {
if (exception.name) {
data.name = exception.name;
} else {
data.name = 'UnknownError';
}
if (exception.message) {
data.message = exception.message;
}
if (exception.lineNumber) {
data.lineNumber = exception.lineNumber;
if (exception.columnNumber) {
data.columnNumber = exception.columnNumber;
}
} else if (exception.stack) {
const lineInfoRegexp = /(.*<anonymous>):(\d*)(:)?(\d*)?/g;
const lineInfoGroups = lineInfoRegexp.exec(exception.stack);
if (lineInfoGroups != null && lineInfoGroups.length >= 3) {
if (isUndefined(lineOffset)) {
lineOffset = -2;
}
data.lineNumber = Number(lineInfoGroups[2]) + lineOffset;
if (lineInfoGroups.length >= 5) {
data.columnNumber = Number(lineInfoGroups[4]);
}
}
}
}
}
return data;
} }
public customTranslation(translationValue: string, defaultValue: string): string { public customTranslation(translationValue: string, defaultValue: string): string {
@ -326,7 +289,7 @@ export class UtilsService {
} else if (index > -1) { } else if (index > -1) {
dataKey.color = this.getMaterialColor(index); dataKey.color = this.getMaterialColor(index);
} }
if (keyInfo.postFuncBody && keyInfo.postFuncBody.length) { if (isNotEmptyTbFunction(keyInfo.postFuncBody)) {
dataKey.usePostProcessing = true; dataKey.usePostProcessing = true;
dataKey.postFuncBody = keyInfo.postFuncBody; dataKey.postFuncBody = keyInfo.postFuncBody;
} }

45
ui-ngx/src/app/core/utils.ts

@ -15,16 +15,22 @@
/// ///
import _ from 'lodash'; import _ from 'lodash';
import { from, Observable, Subject } from 'rxjs'; import { from, Observable, of, ReplaySubject, Subject } from 'rxjs';
import { finalize, share } from 'rxjs/operators'; import { catchError, finalize, share } from 'rxjs/operators';
import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { NULL_UUID } from '@shared/models/id/has-uuid'; import { NULL_UUID } from '@shared/models/id/has-uuid';
import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-type.models'; import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-type.models';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { serverErrorCodesTranslations } from '@shared/models/constants'; import { serverErrorCodesTranslations } from '@shared/models/constants';
import { SubscriptionEntityInfo } from '@core/api/widget-api.models'; import { SubscriptionEntityInfo } from '@core/api/widget-api.models';
import {
CompiledTbFunction,
compileTbFunction, GenericFunction,
isNotEmptyTbFunction,
TbFunction
} from '@shared/models/js-function.models';
const varsRegex = /\${([^}]*)}/g; const varsRegex = /\${([^}]*)}/g;
@ -673,6 +679,39 @@ export function parseFunction(source: any, params: string[] = ['def']): (...args
return res; return res;
} }
export function parseTbFunction<T extends GenericFunction>(http: HttpClient, source: TbFunction, params: string[] = ['def']): Observable<CompiledTbFunction<T>> {
if (isNotEmptyTbFunction(source)) {
return compileTbFunction<T>(http, source, ...params).pipe(
catchError(() => {
return of(null);
}),
share({
connector: () => new ReplaySubject(1),
resetOnError: false,
resetOnComplete: false,
resetOnRefCountZero: false
})
);
} else {
return of(null);
}
}
export function safeExecuteTbFunction<T extends GenericFunction>(func: CompiledTbFunction<T>, params = []) {
let res = null;
if (func) {
try {
res = func.execute(...params);
}
catch (err) {
console.log('error in external function:', err);
res = null;
}
}
return res;
}
export function safeExecute(func: (...args: any[]) => any, params = []) { export function safeExecute(func: (...args: any[]) => any, params = []) {
let res = null; let res = null;
if (func && typeof (func) === 'function') { if (func && typeof (func) === 'function') {

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

@ -107,6 +107,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func formControlName="showWidgetActionFunction" <tb-js-func formControlName="showWidgetActionFunction"
functionTitle="{{ 'widget-config.show-action-function' | translate }}" functionTitle="{{ 'widget-config.show-action-function' | translate }}"
withModules
[helpId]="getWidgetActionFunctionHelpId()" [helpId]="getWidgetActionFunctionHelpId()"
[functionArgs]="['widgetContext', 'data']" [functionArgs]="['widgetContext', 'data']"
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"

11
ui-ngx/src/app/modules/home/components/widget/config/data-key-config-dialog.component.ts

@ -123,10 +123,11 @@ export class DataKeyConfigDialogComponent extends DialogComponent<DataKeyConfigD
save(): void { save(): void {
this.submitted = true; this.submitted = true;
this.dataKeyConfig.validateOnSubmit(); this.dataKeyConfig.validateOnSubmit().subscribe(() => {
if (this.dataKeyFormGroup.valid) { if (this.dataKeyFormGroup.valid) {
const dataKey: DataKey = this.dataKeyFormGroup.get('dataKey').value; const dataKey: DataKey = this.dataKeyFormGroup.get('dataKey').value;
this.dialogRef.close(dataKey); this.dialogRef.close(dataKey);
} }
});
} }
} }

2
ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html

@ -72,6 +72,7 @@
[functionArgs]="['time', 'prevValue']" [functionArgs]="['time', 'prevValue']"
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[validationArgs]="[[1, 1],[1, '1']]" [validationArgs]="[[1, 1],[1, '1']]"
withModules
resultType="any" resultType="any"
helpId="widget/config/datakey_generation_fn" helpId="widget/config/datakey_generation_fn"
formControlName="funcBody"> formControlName="funcBody">
@ -174,6 +175,7 @@
[functionArgs]="['time', 'value', 'prevValue', 'timePrev', 'prevOrigValue']" [functionArgs]="['time', 'value', 'prevValue', 'timePrev', 'prevOrigValue']"
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[validationArgs]="[[1, 1, 1, 1, 1],[1, '1', '1', 1, '1']]" [validationArgs]="[[1, 1, 1, 1, 1],[1, '1', '1', 1, '1']]"
withModules
resultType="any" resultType="any"
helpId="widget/config/datakey_postprocess_fn" helpId="widget/config/datakey_postprocess_fn"
formControlName="postFuncBody"> formControlName="postFuncBody">

17
ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts

@ -57,6 +57,7 @@ import { coerceBoolean } from '@shared/decorators/coercion';
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { WidgetComponentService } from '@home/components/widget/widget-component.service'; import { WidgetComponentService } from '@home/components/widget/widget-component.service';
import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models';
import { isNotEmptyTbFunction, TbFunction } from '@shared/models/js-function.models';
@Component({ @Component({
selector: 'tb-data-key-config', selector: 'tb-data-key-config',
@ -290,10 +291,10 @@ export class DataKeyConfigComponent extends PageComponent implements OnInit, Con
}); });
this.dataKeyFormGroup.get('usePostProcessing').valueChanges.subscribe((usePostProcessing: boolean) => { this.dataKeyFormGroup.get('usePostProcessing').valueChanges.subscribe((usePostProcessing: boolean) => {
const postFuncBody: string = this.dataKeyFormGroup.get('postFuncBody').value; const postFuncBody: TbFunction = this.dataKeyFormGroup.get('postFuncBody').value;
if (usePostProcessing && (!postFuncBody || !postFuncBody.length)) { if (usePostProcessing && !isNotEmptyTbFunction(postFuncBody)) {
this.dataKeyFormGroup.get('postFuncBody').patchValue('return value;'); this.dataKeyFormGroup.get('postFuncBody').patchValue('return value;');
} else if (!usePostProcessing && postFuncBody && postFuncBody.length) { } else if (!usePostProcessing && isNotEmptyTbFunction(postFuncBody)) {
this.dataKeyFormGroup.get('postFuncBody').patchValue(null); this.dataKeyFormGroup.get('postFuncBody').patchValue(null);
} }
}); });
@ -317,7 +318,7 @@ export class DataKeyConfigComponent extends PageComponent implements OnInit, Con
writeValue(value: DataKey): void { writeValue(value: DataKey): void {
this.modelValue = value; this.modelValue = value;
if (this.modelValue.postFuncBody && this.modelValue.postFuncBody.length) { if (isNotEmptyTbFunction(this.modelValue.postFuncBody)) {
this.modelValue.usePostProcessing = true; this.modelValue.usePostProcessing = true;
} }
if (this.widgetType === widgetType.latest && this.modelValue.type === DataKeyType.timeseries && !this.modelValue.aggregationType) { if (this.widgetType === widgetType.latest && this.modelValue.type === DataKeyType.timeseries && !this.modelValue.aggregationType) {
@ -466,13 +467,15 @@ export class DataKeyConfigComponent extends PageComponent implements OnInit, Con
return key => key.toLowerCase().startsWith(lowercaseQuery); return key => key.toLowerCase().startsWith(lowercaseQuery);
} }
public validateOnSubmit() { public validateOnSubmit(): Observable<void> {
if (this.modelValue.type === DataKeyType.function && this.funcBodyEdit) { if (this.modelValue.type === DataKeyType.function && this.funcBodyEdit) {
this.funcBodyEdit.validateOnSubmit(); return this.funcBodyEdit.validateOnSubmit();
} else if ((this.modelValue.type === DataKeyType.timeseries || } else if ((this.modelValue.type === DataKeyType.timeseries ||
this.modelValue.type === DataKeyType.attribute) && this.dataKeyFormGroup.get('usePostProcessing').value && this.modelValue.type === DataKeyType.attribute) && this.dataKeyFormGroup.get('usePostProcessing').value &&
this.postFuncBodyEdit) { this.postFuncBodyEdit) {
this.postFuncBodyEdit.validateOnSubmit(); return this.postFuncBodyEdit.validateOnSubmit();
} else {
return of(null);
} }
} }

13
ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts

@ -24,7 +24,6 @@ import {
import { WidgetContext } from '@home/models/widget-component.models'; import { WidgetContext } from '@home/models/widget-component.models';
import { BehaviorSubject, forkJoin, Observable, Observer, of, Subscription, throwError } from 'rxjs'; import { BehaviorSubject, forkJoin, Observable, Observer, of, Subscription, throwError } from 'rxjs';
import { catchError, delay, map, share, take } from 'rxjs/operators'; import { catchError, delay, map, share, take } from 'rxjs/operators';
import { UtilsService } from '@core/services/utils.service';
import { AfterViewInit, ChangeDetectorRef, Directive, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core'; import { AfterViewInit, ChangeDetectorRef, Directive, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core';
import { import {
DataToValueSettings, DataToValueSettings,
@ -45,6 +44,7 @@ import { ValueType } from '@shared/models/constants';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { isDefinedAndNotNull } from '@core/utils'; import { isDefinedAndNotNull } from '@core/utils';
import { parseError } from '@shared/models/error.models';
@Directive() @Directive()
// eslint-disable-next-line @angular-eslint/directive-class-suffix // eslint-disable-next-line @angular-eslint/directive-class-suffix
@ -110,7 +110,7 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A
} }
}, },
error: (err: any) => { error: (err: any) => {
const message = parseError(this.ctx, err); const message = parseError(err);
this.onError(message); this.onError(message);
if (valueObserver?.error) { if (valueObserver?.error) {
valueObserver.error(err); valueObserver.error(err);
@ -152,7 +152,7 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A
if (setValueObserver?.error) { if (setValueObserver?.error) {
setValueObserver.error(err); setValueObserver.error(err);
} }
const message = parseError(this.ctx, err); const message = parseError(err);
this.onError(message); this.onError(message);
} }
}); });
@ -214,7 +214,7 @@ export abstract class ValueAction {
protected settings: ValueActionSettings) {} protected settings: ValueActionSettings) {}
protected handleError(err: any): Error { protected handleError(err: any): Error {
const reason = parseError(this.ctx, err); const reason = parseError(err);
let errorMessage = this.ctx.translate.instant('widgets.value-action.error.failed-to-perform-action', let errorMessage = this.ctx.translate.instant('widgets.value-action.error.failed-to-perform-action',
{actionLabel: this.settings.actionLabel}); {actionLabel: this.settings.actionLabel});
if (reason) { if (reason) {
@ -709,15 +709,12 @@ export class TimeSeriesValueSetter<V> extends TelemetryValueSetter<V> {
} }
const parseError = (ctx: WidgetContext, err: any): string =>
ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error';
const handleRpcError = (ctx: WidgetContext, err: any): Error => { const handleRpcError = (ctx: WidgetContext, err: any): Error => {
let reason: string; let reason: string;
if (ctx.defaultSubscription.rpcErrorText) { if (ctx.defaultSubscription.rpcErrorText) {
reason = ctx.defaultSubscription.rpcErrorText; reason = ctx.defaultSubscription.rpcErrorText;
} else { } else {
reason = parseError(ctx, err); reason = parseError(err);
} }
return new Error(reason); return new Error(reason);
}; };

10
ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html

@ -70,7 +70,7 @@
[indeterminate]="alarmsDatasource.selection.hasValue() && !(alarmsDatasource.isAllSelected() | async)"> [indeterminate]="alarmsDatasource.selection.hasValue() && !(alarmsDatasource.isAllSelected() | async)">
</mat-checkbox> </mat-checkbox>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let alarm; let row = index" [style]="rowStyle(alarm, row)"> <mat-cell *matCellDef="let alarm; let row = index" [style]="rowStyle(alarm, row) | async">
<mat-checkbox (click)="$event.stopPropagation();" <mat-checkbox (click)="$event.stopPropagation();"
(change)="$event ? alarmsDatasource.toggleSelection(alarm) : null" (change)="$event ? alarmsDatasource.toggleSelection(alarm) : null"
[checked]="alarmsDatasource.isSelected(alarm)"> [checked]="alarmsDatasource.isSelected(alarm)">
@ -82,10 +82,10 @@
{{ column.title }} {{ column.title }}
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let alarm; let row = index" <mat-cell *matCellDef="let alarm; let row = index"
[style]="cellStyle(alarm, column, row)" [style]="cellStyle(alarm, column, row) | async"
(click)="onCellClick($event, alarm, column, $index)" (click)="onCellClick($event, alarm, column, $index)"
[class.tb-pointer]="columnHasCellClick($index)"> [class.tb-pointer]="columnHasCellClick($index)">
<span [innerHTML]="cellContent(alarm, column, row)"></span> <span [innerHTML]="cellContent(alarm, column, row) | async"></span>
<ng-container *ngIf="column.entityKey.key === 'assignee'"> <ng-container *ngIf="column.entityKey.key === 'assignee'">
<span class="assignee-cell flex flex-row items-center justify-start"> <span class="assignee-cell flex flex-row items-center justify-start">
<span *ngIf="alarm.assigneeId && checkAssigneeHasName(alarm.assignee); else unassigned" <span *ngIf="alarm.assigneeId && checkAssigneeHasName(alarm.assignee); else unassigned"
@ -129,7 +129,7 @@
</div> </div>
</ng-container> </ng-container>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let alarm; let row = index" [style]="rowStyle(alarm, row)"> <mat-cell *matCellDef="let alarm; let row = index" [style]="rowStyle(alarm, row) | async">
<ng-container *ngIf="alarmsDatasource.countCellButtonAction"> <ng-container *ngIf="alarmsDatasource.countCellButtonAction">
<div [class.lt-lg:!hidden]="showCellActionsMenu && alarmsDatasource.countCellButtonAction !== 1" class="flex flex-row items-stretch justify-end" <div [class.lt-lg:!hidden]="showCellActionsMenu && alarmsDatasource.countCellButtonAction !== 1" class="flex flex-row items-stretch justify-end"
[style.min-width]="(alarmsDatasource.countCellButtonAction * 48) + 'px'"> [style.min-width]="(alarmsDatasource.countCellButtonAction * 48) + 'px'">
@ -171,7 +171,7 @@
[class.invisible]="alarmsDatasource.dataLoading" [class.invisible]="alarmsDatasource.dataLoading"
[class.tb-pointer]="hasRowAction" [class.tb-pointer]="hasRowAction"
*matRowDef="let alarm; columns: displayedColumns; let row = index" *matRowDef="let alarm; columns: displayedColumns; let row = index"
[style]="rowStyle(alarm, row)" [style]="rowStyle(alarm, row) | async"
(click)="onRowClick($event, alarm)"></mat-row> (click)="onRowClick($event, alarm)"></mat-row>
</table> </table>
<span [class.!hidden]="(alarmsDatasource.isEmpty() | async) === false || alarmsDatasource.dataLoading" <span [class.!hidden]="(alarmsDatasource.isEmpty() | async) === false || alarmsDatasource.dataLoading"

293
ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts

@ -51,9 +51,9 @@ import cssjs from '@core/css/css';
import { sortItems } from '@shared/models/page/page-link'; import { sortItems } from '@shared/models/page/page-link';
import { Direction } from '@shared/models/page/sort-order'; import { Direction } from '@shared/models/page/sort-order';
import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections'; import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections';
import { BehaviorSubject, forkJoin, fromEvent, merge, Observable, Subject, Subscription } from 'rxjs'; import { BehaviorSubject, forkJoin, fromEvent, merge, Observable, of, Subject, Subscription } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { debounceTime, distinctUntilChanged, map, take, takeUntil, tap } from 'rxjs/operators'; import { catchError, debounceTime, distinctUntilChanged, map, take, takeUntil, tap } from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatSort, SortDirection } from '@angular/material/sort'; import { MatSort, SortDirection } from '@angular/material/sort';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@ -69,7 +69,7 @@ import {
findEntityKeyByColumnDef, findEntityKeyByColumnDef,
fromEntityColumnDef, fromEntityColumnDef,
getAlarmValue, getAlarmValue,
getCellContentInfo, getCellContentFunctionInfo,
getCellStyleInfo, getCellStyleInfo,
getColumnDefaultVisibility, getColumnDefaultVisibility,
getColumnSelectionAvailability, getColumnSelectionAvailability,
@ -217,13 +217,13 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
private defaultSortOrder = '-' + alarmFields.createdTime.value; private defaultSortOrder = '-' + alarmFields.createdTime.value;
private contentsInfo: {[key: string]: CellContentInfo} = {}; private contentsInfo: {[key: string]: CellContentInfo} = {};
private stylesInfo: {[key: string]: CellStyleInfo} = {}; private stylesInfo: {[key: string]: Observable<CellStyleInfo>} = {};
private columnWidth: {[key: string]: string} = {}; private columnWidth: {[key: string]: string} = {};
private columnDefaultVisibility: {[key: string]: boolean} = {}; private columnDefaultVisibility: {[key: string]: boolean} = {};
private columnSelectionAvailability: {[key: string]: boolean} = {}; private columnSelectionAvailability: {[key: string]: boolean} = {};
private columnsWithCellClick: Array<number> = []; private columnsWithCellClick: Array<number> = [];
private rowStylesInfo: RowStyleInfo; private rowStylesInfo: Observable<RowStyleInfo>;
private widgetTimewindowChanged$: Subscription; private widgetTimewindowChanged$: Subscription;
@ -389,7 +389,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} }
this.alarmFilterAction.show = enableFilter; this.alarmFilterAction.show = enableFilter;
this.rowStylesInfo = getRowStyleInfo(this.settings, 'alarm, ctx'); this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'alarm, ctx');
const pageSize = this.settings.defaultPageSize; const pageSize = this.settings.defaultPageSize;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
@ -438,10 +438,14 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
keySettings.columnWidth = '120px'; keySettings.columnWidth = '120px';
} }
} }
this.stylesInfo[dataKey.def] = getCellStyleInfo(keySettings, 'value, alarm, ctx'); this.stylesInfo[dataKey.def] = getCellStyleInfo(this.ctx, keySettings, 'value, alarm, ctx');
this.contentsInfo[dataKey.def] = getCellContentInfo(keySettings, 'value, alarm, ctx'); const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, alarm, ctx');
this.contentsInfo[dataKey.def].units = dataKey.units; const contentInfo: CellContentInfo = {
this.contentsInfo[dataKey.def].decimals = dataKey.decimals; contentFunction: contentFunctionInfo,
units: dataKey.units,
decimals: dataKey.decimals
};
this.contentsInfo[dataKey.def] = contentInfo;
this.columnWidth[dataKey.def] = getColumnWidth(keySettings); this.columnWidth[dataKey.def] = getColumnWidth(keySettings);
this.columnDefaultVisibility[dataKey.def] = getColumnDefaultVisibility(keySettings, this.ctx); this.columnDefaultVisibility[dataKey.def] = getColumnDefaultVisibility(keySettings, this.ctx);
this.columnSelectionAvailability[dataKey.def] = getColumnSelectionAvailability(keySettings); this.columnSelectionAvailability[dataKey.def] = getColumnSelectionAvailability(keySettings);
@ -719,100 +723,135 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
return widthStyle(columnWidth); return widthStyle(columnWidth);
} }
public rowStyle(alarm: AlarmDataInfo, row: number): any { public rowStyle(alarm: AlarmDataInfo, row: number): Observable<any> {
let style$: Observable<any>;
let res = this.rowStyleCache[row]; let res = this.rowStyleCache[row];
if (!res) { if (!res) {
res = {}; style$ = this.rowStylesInfo.pipe(
if (alarm && this.rowStylesInfo.useRowStyleFunction && this.rowStylesInfo.rowStyleFunction) { map(styleInfo => {
try { if (styleInfo.useRowStyleFunction && styleInfo.rowStyleFunction) {
res = this.rowStylesInfo.rowStyleFunction(alarm, this.ctx); const style = styleInfo.rowStyleFunction.execute(alarm, this.ctx);
if (!isObject(res)) { if (!isObject(style)) {
throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); throw new TypeError(`${style === null ? 'null' : typeof style} instead of style object`);
} }
if (Array.isArray(res)) { if (Array.isArray(style)) {
throw new TypeError(`Array instead of style object`); throw new TypeError(`Array instead of style object`);
}
return style;
} else {
return {};
} }
} catch (e) { }),
res = {}; catchError(e => {
console.warn(`Row style function in widget '${this.ctx.widgetTitle}' ` + console.warn(`Row style function in widget '${this.ctx.widgetTitle}' ` +
`returns '${e}'. Please check your row style function.`); `returns '${e}'. Please check your row style function.`);
} return of({});
} })
this.rowStyleCache[row] = res; );
style$ = style$.pipe(
tap((style) => {
this.rowStyleCache[row] = style;
})
);
} else {
style$ = of(res);
} }
return res; return style$;
} }
public cellStyle(alarm: AlarmDataInfo, key: EntityColumn, row: number): any { public cellStyle(alarm: AlarmDataInfo, key: EntityColumn, row: number): Observable<any> {
let style$: Observable<any>;
const col = this.columns.indexOf(key); const col = this.columns.indexOf(key);
const index = row * this.columns.length + col; const index = row * this.columns.length + col;
let res = this.cellStyleCache[index]; let res = this.cellStyleCache[index];
if (!res) { if (!res) {
res = {};
if (alarm && key) { if (alarm && key) {
const styleInfo = this.stylesInfo[key.def]; style$ = this.stylesInfo[key.def].pipe(
const value = getAlarmValue(alarm, key); map(styleInfo => {
if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { const value = getAlarmValue(alarm, key);
try { if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {
res = styleInfo.cellStyleFunction(value, alarm, this.ctx); const style = styleInfo.cellStyleFunction.execute(value, alarm, this.ctx);
if (!isObject(res)) { if (!isObject(style)) {
throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); throw new TypeError(`${style === null ? 'null' : typeof style} instead of style object`);
}
if (Array.isArray(style)) {
throw new TypeError(`Array instead of style object`);
}
return style;
} else {
return this.defaultStyle(key, value);
} }
if (Array.isArray(res)) { }),
throw new TypeError(`Array instead of style object`); catchError(e => {
}
} catch (e) {
res = {};
console.warn(`Cell style function for data key '${key.label}' in widget '${this.ctx.widgetTitle}' ` + console.warn(`Cell style function for data key '${key.label}' in widget '${this.ctx.widgetTitle}' ` +
`returns '${e}'. Please check your cell style function.`); `returns '${e}'. Please check your cell style function.`);
} return of({});
} else { })
res = this.defaultStyle(key, value); );
} } else {
style$ = of({});
} }
this.cellStyleCache[index] = res; style$ = style$.pipe(
} map((style) => {
if (!res.width) { if (!style.width) {
const columnWidth = this.columnWidth[key.def]; const columnWidth = this.columnWidth[key.def];
res = Object.assign(res, widthStyle(columnWidth)); style = Object.assign(style, widthStyle(columnWidth));
}
return style;
}),
tap((style) => {
this.cellStyleCache[index] = style;
})
);
} else {
style$ = of(res);
} }
return res; return style$;
} }
public cellContent(alarm: AlarmDataInfo, key: EntityColumn, row: number): SafeHtml { public cellContent(alarm: AlarmDataInfo, key: EntityColumn, row: number): Observable<SafeHtml> {
let content$: Observable<SafeHtml>;
const col = this.columns.indexOf(key); const col = this.columns.indexOf(key);
const index = row * this.columns.length + col; const index = row * this.columns.length + col;
let res = this.cellContentCache[index]; let res = this.cellContentCache[index];
if (isUndefined(res)) { if (isUndefined(res)) {
res = ''; const contentInfo = this.contentsInfo[key.def];
if (alarm && key) { content$ = contentInfo.contentFunction.pipe(
const contentInfo = this.contentsInfo[key.def]; map((contentFunction) => {
const value = getAlarmValue(alarm, key); let content: any = '';
let content = ''; if (alarm && key) {
if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { const contentInfo = this.contentsInfo[key.def];
try { const value = getAlarmValue(alarm, key);
content = contentInfo.cellContentFunction(value, alarm, this.ctx); if (contentFunction.useCellContentFunction && contentFunction.cellContentFunction) {
} catch (e) { try {
content = '' + value; content = contentFunction.cellContentFunction.execute(value, alarm, this.ctx);
} } catch (e) {
} else { content = '' + value;
content = this.defaultContent(key, contentInfo, value); }
} } else {
content = this.defaultContent(key, contentInfo, value);
if (isDefined(content)) { }
content = this.utils.customTranslation(content, content); if (isDefined(content)) {
switch (typeof content) { content = this.utils.customTranslation(content, content);
case 'string': switch (typeof content) {
res = this.domSanitizer.bypassSecurityTrustHtml(content); case 'string':
break; content = this.domSanitizer.bypassSecurityTrustHtml(content);
default: break;
res = content; }
}
} }
} return content;
} })
this.cellContentCache[index] = res; );
content$ = content$.pipe(
tap((content) => {
this.cellContentCache[index] = content;
})
);
} else {
content$ = of(res);
} }
return res; return content$;
} }
public onCellClick($event: Event, alarm: AlarmDataInfo, key: EntityColumn, columnIndex: number) { public onCellClick($event: Event, alarm: AlarmDataInfo, key: EntityColumn, columnIndex: number) {
@ -1196,20 +1235,32 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
private reserveSpaceForHiddenAction = true; private reserveSpaceForHiddenAction = true;
private cellButtonActions: TableCellButtonActionDescriptor[]; private cellButtonActions: TableCellButtonActionDescriptor[];
private readonly usedShowCellActionFunction: boolean; private usedShowCellActionFunction: boolean;
private inited = false;
constructor(private subscription: IWidgetSubscription, constructor(private subscription: IWidgetSubscription,
private dataKeys: Array<DataKey>, private dataKeys: Array<DataKey>,
private ngZone: NgZone, private ngZone: NgZone,
private widgetContext: WidgetContext, private widgetContext: WidgetContext,
actionCellDescriptors: AlarmWidgetActionDescriptor[]) { private actionCellDescriptors: AlarmWidgetActionDescriptor[]) {
this.cellButtonActions = actionCellDescriptors.concat(getTableCellButtonActions(widgetContext));
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
if (this.widgetContext.settings.reserveSpaceForHiddenAction) { if (this.widgetContext.settings.reserveSpaceForHiddenAction) {
this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction); this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction);
} }
} }
private init(): Observable<any> {
if (this.inited) {
return of(null);
}
return getTableCellButtonActions(this.widgetContext).pipe(
tap(actions => {
this.cellButtonActions = this.actionCellDescriptors.concat(actions);
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
this.inited = true;
})
);
}
connect(collectionViewer: CollectionViewer): Observable<AlarmDataInfo[] | ReadonlyArray<AlarmDataInfo>> { connect(collectionViewer: CollectionViewer): Observable<AlarmDataInfo[] | ReadonlyArray<AlarmDataInfo>> {
return this.alarmsSubject.asObservable(); return this.alarmsSubject.asObservable();
} }
@ -1237,47 +1288,49 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
} }
updateAlarms() { updateAlarms() {
const subscriptionAlarms = this.subscription.alarms; this.init().subscribe(() => {
let alarms = new Array<AlarmDataInfo>(); const subscriptionAlarms = this.subscription.alarms;
let maxCellButtonAction = 0; let alarms = new Array<AlarmDataInfo>();
let isEmptySelection = false; let maxCellButtonAction = 0;
const dynamicWidthCellButtonActions = this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction; let isEmptySelection = false;
subscriptionAlarms.data.forEach((alarmData) => { const dynamicWidthCellButtonActions = this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction;
const alarm = this.alarmDataToInfo(alarmData); subscriptionAlarms.data.forEach((alarmData) => {
alarms.push(alarm); const alarm = this.alarmDataToInfo(alarmData);
if (dynamicWidthCellButtonActions && alarm.actionCellButtons.length > maxCellButtonAction) { alarms.push(alarm);
maxCellButtonAction = alarm.actionCellButtons.length; if (dynamicWidthCellButtonActions && alarm.actionCellButtons.length > maxCellButtonAction) {
maxCellButtonAction = alarm.actionCellButtons.length;
}
});
if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && alarms.length) {
maxCellButtonAction = alarms[0].actionCellButtons.length;
} }
}); if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) {
if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && alarms.length) { const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC;
maxCellButtonAction = alarms[0].actionCellButtons.length; alarms = alarms.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc));
}
if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) {
const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC;
alarms = alarms.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc));
}
if (this.selection.hasValue()) {
const alarmIds = alarms.map((alarm) => alarm.id.id);
const toRemove = this.selection.selected.filter(alarm => alarmIds.indexOf(alarm.id.id) === -1);
this.selection.deselect(...toRemove);
if (this.selection.isEmpty()) {
isEmptySelection = true;
} }
} if (this.selection.hasValue()) {
const alarmsPageData: PageData<AlarmDataInfo> = { const alarmIds = alarms.map((alarm) => alarm.id.id);
data: alarms, const toRemove = this.selection.selected.filter(alarm => alarmIds.indexOf(alarm.id.id) === -1);
totalPages: subscriptionAlarms.totalPages, this.selection.deselect(...toRemove);
totalElements: subscriptionAlarms.totalElements, if (this.selection.isEmpty()) {
hasNext: subscriptionAlarms.hasNext isEmptySelection = true;
}; }
this.ngZone.run(() => {
if (isEmptySelection) {
this.onSelectionModeChanged(false);
} }
this.alarmsSubject.next(alarms); const alarmsPageData: PageData<AlarmDataInfo> = {
this.pageDataSubject.next(alarmsPageData); data: alarms,
this.countCellButtonAction = maxCellButtonAction; totalPages: subscriptionAlarms.totalPages,
this.dataLoading = false; totalElements: subscriptionAlarms.totalElements,
hasNext: subscriptionAlarms.hasNext
};
this.ngZone.run(() => {
if (isEmptySelection) {
this.onSelectionModeChanged(false);
}
this.alarmsSubject.next(alarms);
this.pageDataSubject.next(alarmsPageData);
this.countCellButtonAction = maxCellButtonAction;
this.dataLoading = false;
});
}); });
} }

8
ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html

@ -43,8 +43,8 @@
<ng-container [matColumnDef]="column.def" *ngFor="let column of columns; let $index = index; trackBy: trackByColumnDef;"> <ng-container [matColumnDef]="column.def" *ngFor="let column of columns; let $index = index; trackBy: trackByColumnDef;">
<mat-header-cell [style]="headerStyle(column)" *matHeaderCellDef mat-sort-header [disabled]="!column.sortable"> {{ column.title }} </mat-header-cell> <mat-header-cell [style]="headerStyle(column)" *matHeaderCellDef mat-sort-header [disabled]="!column.sortable"> {{ column.title }} </mat-header-cell>
<mat-cell *matCellDef="let entity; let row = index" <mat-cell *matCellDef="let entity; let row = index"
[innerHTML]="cellContent(entity, column, row)" [innerHTML]="cellContent(entity, column, row) | async"
[style]="cellStyle(entity, column, row)" [style]="cellStyle(entity, column, row) | async"
(click)="onCellClick($event, entity, column, $index)" (click)="onCellClick($event, entity, column, $index)"
[class.tb-pointer]="columnHasCellClick($index)"> [class.tb-pointer]="columnHasCellClick($index)">
</mat-cell> </mat-cell>
@ -59,7 +59,7 @@
</div> </div>
</ng-container> </ng-container>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let entity; let row = index" [style]="rowStyle(entity, row)"> <mat-cell *matCellDef="let entity; let row = index" [style]="rowStyle(entity, row) | async">
<ng-container *ngIf="entityDatasource.countCellButtonAction"> <ng-container *ngIf="entityDatasource.countCellButtonAction">
<div [class.lt-lg:!hidden]="showCellActionsMenu && entityDatasource.countCellButtonAction !== 1" class="flex flex-row items-stretch justify-end" <div [class.lt-lg:!hidden]="showCellActionsMenu && entityDatasource.countCellButtonAction !== 1" class="flex flex-row items-stretch justify-end"
[style.min-width]="(entityDatasource.countCellButtonAction * 48) + 'px'"> [style.min-width]="(entityDatasource.countCellButtonAction * 48) + 'px'">
@ -99,7 +99,7 @@
[class.invisible]="entityDatasource.dataLoading" [class.invisible]="entityDatasource.dataLoading"
[class.tb-pointer]="hasRowAction" [class.tb-pointer]="hasRowAction"
*matRowDef="let entity; columns: displayedColumns; let row = index" *matRowDef="let entity; columns: displayedColumns; let row = index"
[style]="rowStyle(entity, row)" [style]="rowStyle(entity, row) | async"
(click)="onRowClick($event, entity)" (dblclick)="onRowClick($event, entity, true)"></mat-row> (click)="onRowClick($event, entity)" (dblclick)="onRowClick($event, entity, true)"></mat-row>
</table> </table>
<span [class.!hidden]="(entityDatasource.isEmpty() | async) === false || entityDatasource.dataLoading" <span [class.!hidden]="(entityDatasource.isEmpty() | async) === false || entityDatasource.dataLoading"

301
ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts

@ -46,11 +46,11 @@ import { deepClone, hashCode, isDefined, isNumber, isObject, isUndefined } from
import cssjs from '@core/css/css'; import cssjs from '@core/css/css';
import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { BehaviorSubject, fromEvent, merge, Observable, Subject } from 'rxjs'; import { BehaviorSubject, fromEvent, merge, Observable, of, Subject } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs/operators'; import { catchError, debounceTime, distinctUntilChanged, map, takeUntil, tap } from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatSort, SortDirection } from '@angular/material/sort'; import { MatSort, SortDirection } from '@angular/material/sort';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@ -66,7 +66,7 @@ import {
findColumnByEntityKey, findColumnByEntityKey,
findEntityKeyByColumnDef, findEntityKeyByColumnDef,
fromEntityColumnDef, fromEntityColumnDef,
getCellContentInfo, getCellContentFunctionInfo,
getCellStyleInfo, getCellStyleInfo,
getColumnDefaultVisibility, getColumnDefaultVisibility,
getColumnSelectionAvailability, getColumnSelectionAvailability,
@ -106,6 +106,7 @@ import { hidePageSizePixelValue } from '@shared/models/constants';
import { AggregationType } from '@shared/models/time/time.models'; import { AggregationType } from '@shared/models/time/time.models';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models'; import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models';
import { CompiledTbFunction } from '@shared/models/js-function.models';
interface EntitiesTableWidgetSettings extends TableWidgetSettings { interface EntitiesTableWidgetSettings extends TableWidgetSettings {
entitiesTitle: string; entitiesTitle: string;
@ -164,13 +165,13 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
private defaultSortOrder = 'entityName'; private defaultSortOrder = 'entityName';
private contentsInfo: {[key: string]: CellContentInfo} = {}; private contentsInfo: {[key: string]: CellContentInfo} = {};
private stylesInfo: {[key: string]: CellStyleInfo} = {}; private stylesInfo: {[key: string]: Observable<CellStyleInfo>} = {};
private columnWidth: {[key: string]: string} = {}; private columnWidth: {[key: string]: string} = {};
private columnDefaultVisibility: {[key: string]: boolean} = {}; private columnDefaultVisibility: {[key: string]: boolean} = {};
private columnSelectionAvailability: {[key: string]: boolean} = {}; private columnSelectionAvailability: {[key: string]: boolean} = {};
private columnsWithCellClick: Array<number> = []; private columnsWithCellClick: Array<number> = [];
private rowStylesInfo: RowStyleInfo; private rowStylesInfo: Observable<RowStyleInfo>;
private searchAction: WidgetAction = { private searchAction: WidgetAction = {
name: 'action.search', name: 'action.search',
@ -307,7 +308,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
this.showCellActionsMenu = isDefined(this.settings.showCellActionsMenu) ? this.settings.showCellActionsMenu : true; this.showCellActionsMenu = isDefined(this.settings.showCellActionsMenu) ? this.settings.showCellActionsMenu : true;
this.columnDisplayAction.show = isDefined(this.settings.enableSelectColumnDisplay) ? this.settings.enableSelectColumnDisplay : true; this.columnDisplayAction.show = isDefined(this.settings.enableSelectColumnDisplay) ? this.settings.enableSelectColumnDisplay : true;
this.rowStylesInfo = getRowStyleInfo(this.settings, 'entity, ctx'); this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'entity, ctx');
const pageSize = this.settings.defaultPageSize; const pageSize = this.settings.defaultPageSize;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
@ -361,11 +362,13 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
} as EntityColumn } as EntityColumn
); );
this.contentsInfo.entityName = { this.contentsInfo.entityName = {
useCellContentFunction: false contentFunction: of({
useCellContentFunction: false
})
}; };
this.stylesInfo.entityName = { this.stylesInfo.entityName = of({
useCellStyleFunction: false useCellStyleFunction: false
}; });
this.columnWidth.entityName = '0px'; this.columnWidth.entityName = '0px';
this.columnDefaultVisibility.entityName = true; this.columnDefaultVisibility.entityName = true;
this.columnSelectionAvailability.entityName = true; this.columnSelectionAvailability.entityName = true;
@ -385,11 +388,13 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
} as EntityColumn } as EntityColumn
); );
this.contentsInfo.entityLabel = { this.contentsInfo.entityLabel = {
useCellContentFunction: false contentFunction: of({
useCellContentFunction: false
})
}; };
this.stylesInfo.entityLabel = { this.stylesInfo.entityLabel = of({
useCellStyleFunction: false useCellStyleFunction: false
}; });
this.columnWidth.entityLabel = '0px'; this.columnWidth.entityLabel = '0px';
this.columnDefaultVisibility.entityLabel = true; this.columnDefaultVisibility.entityLabel = true;
this.columnSelectionAvailability.entityLabel = true; this.columnSelectionAvailability.entityLabel = true;
@ -409,13 +414,18 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
} as EntityColumn } as EntityColumn
); );
this.contentsInfo.entityType = { this.contentsInfo.entityType = {
useCellContentFunction: true, contentFunction: of({
cellContentFunction: (entityType: EntityType) => useCellContentFunction: true,
entityType ? this.translate.instant(entityTypeTranslations.get(entityType).type) : '' cellContentFunction: new CompiledTbFunction(
(entityType: EntityType) =>
entityType ? this.translate.instant(entityTypeTranslations.get(entityType).type) : '',
[]
)
})
}; };
this.stylesInfo.entityType = { this.stylesInfo.entityType = of({
useCellStyleFunction: false useCellStyleFunction: false
}; });
this.columnWidth.entityType = '0px'; this.columnWidth.entityType = '0px';
this.columnDefaultVisibility.entityType = true; this.columnDefaultVisibility.entityType = true;
this.columnSelectionAvailability.entityType = true; this.columnSelectionAvailability.entityType = true;
@ -447,8 +457,14 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
} }
} }
this.stylesInfo[dataKey.def] = getCellStyleInfo(keySettings, 'value, entity, ctx'); this.stylesInfo[dataKey.def] = getCellStyleInfo(this.ctx, keySettings, 'value, entity, ctx');
this.contentsInfo[dataKey.def] = getCellContentInfo(keySettings, 'value, entity, ctx'); const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, entity, ctx');
const contentInfo: CellContentInfo = {
contentFunction: contentFunctionInfo,
units: dataKey.units,
decimals: dataKey.decimals
};
this.contentsInfo[dataKey.def] = contentInfo;
this.contentsInfo[dataKey.def].units = dataKey.units; this.contentsInfo[dataKey.def].units = dataKey.units;
this.contentsInfo[dataKey.def].decimals = dataKey.decimals; this.contentsInfo[dataKey.def].decimals = dataKey.decimals;
this.columnWidth[dataKey.def] = getColumnWidth(keySettings); this.columnWidth[dataKey.def] = getColumnWidth(keySettings);
@ -595,98 +611,135 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
return widthStyle(columnWidth); return widthStyle(columnWidth);
} }
public rowStyle(entity: EntityData, row: number): any { public rowStyle(entity: EntityData, row: number): Observable<any> {
let style$: Observable<any>;
let res = this.rowStyleCache[row]; let res = this.rowStyleCache[row];
if (!res) { if (!res) {
res = {}; style$ = this.rowStylesInfo.pipe(
if (entity && this.rowStylesInfo.useRowStyleFunction && this.rowStylesInfo.rowStyleFunction) { map(styleInfo => {
try { if (styleInfo.useRowStyleFunction && styleInfo.rowStyleFunction) {
res = this.rowStylesInfo.rowStyleFunction(entity, this.ctx); const style = styleInfo.rowStyleFunction.execute(entity, this.ctx);
if (!isObject(res)) { if (!isObject(style)) {
throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); throw new TypeError(`${style === null ? 'null' : typeof style} instead of style object`);
} }
if (Array.isArray(res)) { if (Array.isArray(style)) {
throw new TypeError(`Array instead of style object`); throw new TypeError(`Array instead of style object`);
}
return style;
} else {
return {};
} }
} catch (e) { }),
res = {}; catchError(e => {
console.warn(`Row style function in widget '${this.ctx.widgetTitle}' ` + console.warn(`Row style function in widget '${this.ctx.widgetTitle}' ` +
`returns '${e}'. Please check your row style function.`); `returns '${e}'. Please check your row style function.`);
} return of({});
} })
this.rowStyleCache[row] = res; );
style$ = style$.pipe(
tap((style) => {
this.rowStyleCache[row] = style;
})
);
} else {
style$ = of(res);
} }
return res; return style$;
} }
public cellStyle(entity: EntityData, key: EntityColumn, row: number): any { public cellStyle(entity: EntityData, key: EntityColumn, row: number): Observable<any> {
let style$: Observable<any>;
const col = this.columns.indexOf(key); const col = this.columns.indexOf(key);
const index = row * this.columns.length + col; const index = row * this.columns.length + col;
let res = this.cellStyleCache[index]; let res = this.cellStyleCache[index];
if (!res) { if (!res) {
res = {};
if (entity && key) { if (entity && key) {
const styleInfo = this.stylesInfo[key.def]; style$ = this.stylesInfo[key.def].pipe(
const value = getEntityValue(entity, key); map((styleInfo) => {
if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { const value = getEntityValue(entity, key);
try { if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {
res = styleInfo.cellStyleFunction(value, entity, this.ctx); const style = styleInfo.cellStyleFunction.execute(value, entity, this.ctx);
if (!isObject(res)) { if (!isObject(style)) {
throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); throw new TypeError(`${style === null ? 'null' : typeof style} instead of style object`);
}
if (Array.isArray(style)) {
throw new TypeError(`Array instead of style object`);
}
return style;
} else {
return {};
} }
if (Array.isArray(res)) { }),
throw new TypeError(`Array instead of style object`); catchError(e => {
}
} catch (e) {
res = {};
console.warn(`Cell style function for data key '${key.label}' in widget '${this.ctx.widgetTitle}' ` + console.warn(`Cell style function for data key '${key.label}' in widget '${this.ctx.widgetTitle}' ` +
`returns '${e}'. Please check your cell style function.`); `returns '${e}'. Please check your cell style function.`);
} return of({});
} })
this.cellStyleCache[index] = res; );
} else {
style$ = of({});
} }
style$ = style$.pipe(
map((style) => {
if (!style.width) {
const columnWidth = this.columnWidth[key.def];
style = Object.assign(style, widthStyle(columnWidth));
}
return style;
}),
tap((style) => {
this.cellStyleCache[index] = style;
})
);
} else {
style$ = of(res);
} }
if (!res.width) { return style$;
const columnWidth = this.columnWidth[key.def];
res = Object.assign(res, widthStyle(columnWidth));
}
return res;
} }
public cellContent(entity: EntityData, key: EntityColumn, row: number): SafeHtml { public cellContent(entity: EntityData, key: EntityColumn, row: number): Observable<SafeHtml> {
let content$: Observable<SafeHtml>;
const col = this.columns.indexOf(key); const col = this.columns.indexOf(key);
const index = row * this.columns.length + col; const index = row * this.columns.length + col;
let res = this.cellContentCache[index]; let res = this.cellContentCache[index];
if (isUndefined(res)) { if (isUndefined(res)) {
res = ''; const contentInfo = this.contentsInfo[key.def];
if (entity && key) { content$ = contentInfo.contentFunction.pipe(
const contentInfo = this.contentsInfo[key.def]; map((contentFunction) => {
const value = getEntityValue(entity, key); let content: any = '';
let content: string; if (entity && key) {
if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { const contentInfo = this.contentsInfo[key.def];
try { const value = getEntityValue(entity, key);
content = contentInfo.cellContentFunction(value, entity, this.ctx); if (contentFunction.useCellContentFunction && contentFunction.cellContentFunction) {
} catch (e) { try {
content = '' + value; content = contentFunction.cellContentFunction.execute(value, entity, this.ctx);
} } catch (e) {
} else { content = '' + value;
content = this.defaultContent(key, contentInfo, value); }
} } else {
content = this.defaultContent(key, contentInfo, value);
if (isDefined(content)) { }
content = this.utils.customTranslation(content, content); if (isDefined(content)) {
switch (typeof content) { content = this.utils.customTranslation(content, content);
case 'string': switch (typeof content) {
res = this.domSanitizer.bypassSecurityTrustHtml(content); case 'string':
break; content = this.domSanitizer.bypassSecurityTrustHtml(content);
default: break;
res = content; }
}
} }
} return content;
} })
this.cellContentCache[index] = res; );
content$ = content$.pipe(
tap((content) => {
this.cellContentCache[index] = content;
})
);
} else {
content$ = of(res);
} }
return res; return content$;
} }
private defaultContent(key: EntityColumn, contentInfo: CellContentInfo, value: any): any { private defaultContent(key: EntityColumn, contentInfo: CellContentInfo, value: any): any {
@ -789,7 +842,8 @@ class EntityDatasource implements DataSource<EntityData> {
private reserveSpaceForHiddenAction = true; private reserveSpaceForHiddenAction = true;
private cellButtonActions: TableCellButtonActionDescriptor[]; private cellButtonActions: TableCellButtonActionDescriptor[];
private readonly usedShowCellActionFunction: boolean; private usedShowCellActionFunction: boolean;
private inited = false;
constructor( constructor(
private translate: TranslateService, private translate: TranslateService,
@ -798,13 +852,24 @@ class EntityDatasource implements DataSource<EntityData> {
private ngZone: NgZone, private ngZone: NgZone,
private widgetContext: WidgetContext private widgetContext: WidgetContext
) { ) {
this.cellButtonActions = getTableCellButtonActions(widgetContext);
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
if (this.widgetContext.settings.reserveSpaceForHiddenAction) { if (this.widgetContext.settings.reserveSpaceForHiddenAction) {
this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction); this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction);
} }
} }
private init(): Observable<any> {
if (this.inited) {
return of(null);
}
return getTableCellButtonActions(this.widgetContext).pipe(
tap(actions => {
this.cellButtonActions = actions
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
this.inited = true;
})
);
}
connect(collectionViewer: CollectionViewer): Observable<EntityData[] | ReadonlyArray<EntityData>> { connect(collectionViewer: CollectionViewer): Observable<EntityData[] | ReadonlyArray<EntityData>> {
return this.entitiesSubject.asObservable(); return this.entitiesSubject.asObservable();
} }
@ -828,36 +893,38 @@ class EntityDatasource implements DataSource<EntityData> {
} }
dataUpdated() { dataUpdated() {
const datasourcesPageData = this.subscription.datasourcePages[0]; this.init().subscribe(() => {
const dataPageData = this.subscription.dataPages[0]; const datasourcesPageData = this.subscription.datasourcePages[0];
let entities = new Array<EntityData>(); const dataPageData = this.subscription.dataPages[0];
let maxCellButtonAction = 0; let entities = new Array<EntityData>();
const dynamicWidthCellButtonActions = this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction; let maxCellButtonAction = 0;
datasourcesPageData.data.forEach((datasource, index) => { const dynamicWidthCellButtonActions = this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction;
const entity = this.datasourceToEntityData(datasource, dataPageData.data[index]); datasourcesPageData.data.forEach((datasource, index) => {
entities.push(entity); const entity = this.datasourceToEntityData(datasource, dataPageData.data[index]);
if (dynamicWidthCellButtonActions && entity.actionCellButtons.length > maxCellButtonAction) { entities.push(entity);
maxCellButtonAction = entity.actionCellButtons.length; if (dynamicWidthCellButtonActions && entity.actionCellButtons.length > maxCellButtonAction) {
maxCellButtonAction = entity.actionCellButtons.length;
}
});
if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) {
const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC;
entities = entities.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc));
} }
}); if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && entities.length) {
if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) { maxCellButtonAction = entities[0].actionCellButtons.length;
const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC; }
entities = entities.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc)); const entitiesPageData: PageData<EntityData> = {
} data: entities,
if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && entities.length) { totalPages: datasourcesPageData.totalPages,
maxCellButtonAction = entities[0].actionCellButtons.length; totalElements: datasourcesPageData.totalElements,
} hasNext: datasourcesPageData.hasNext
const entitiesPageData: PageData<EntityData> = { };
data: entities, this.ngZone.run(() => {
totalPages: datasourcesPageData.totalPages, this.entitiesSubject.next(entities);
totalElements: datasourcesPageData.totalElements, this.pageDataSubject.next(entitiesPageData);
hasNext: datasourcesPageData.hasNext this.countCellButtonAction = maxCellButtonAction;
}; this.dataLoading = false;
this.ngZone.run(() => { });
this.entitiesSubject.next(entities);
this.pageDataSubject.next(entitiesPageData);
this.countCellButtonAction = maxCellButtonAction;
this.dataLoading = false;
}); });
} }

26
ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts

@ -28,18 +28,21 @@ import {
hashCode, hashCode,
isDefinedAndNotNull, isDefinedAndNotNull,
isNotEmptyStr, isNotEmptyStr,
parseFunction, parseTbFunction,
safeExecute safeExecuteTbFunction
} from '@core/utils'; } from '@core/utils';
import cssjs from '@core/css/css'; import cssjs from '@core/css/css';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens';
import { EntityDataPageLink } from '@shared/models/query/query.models'; import { EntityDataPageLink } from '@shared/models/query/query.models';
import { CompiledTbFunction, TbFunction } from '@shared/models/js-function.models';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
interface MarkdownWidgetSettings { interface MarkdownWidgetSettings {
markdownTextPattern: string; markdownTextPattern: string;
useMarkdownTextFunction: boolean; useMarkdownTextFunction: boolean;
markdownTextFunction: string; markdownTextFunction: TbFunction;
applyDefaultMarkdownStyle: boolean; applyDefaultMarkdownStyle: boolean;
markdownCss: string; markdownCss: string;
} }
@ -53,7 +56,7 @@ type MarkdownTextFunction = (data: FormattedData[], ctx: WidgetContext) => strin
export class MarkdownWidgetComponent extends PageComponent implements OnInit { export class MarkdownWidgetComponent extends PageComponent implements OnInit {
settings: MarkdownWidgetSettings; settings: MarkdownWidgetSettings;
markdownTextFunction: MarkdownTextFunction; markdownTextFunction: Observable<CompiledTbFunction<MarkdownTextFunction>>;
markdownClass: string; markdownClass: string;
@ -78,7 +81,7 @@ export class MarkdownWidgetComponent extends PageComponent implements OnInit {
this.ctx.$scope.markdownWidget = this; this.ctx.$scope.markdownWidget = this;
this.settings = this.ctx.settings; this.settings = this.ctx.settings;
this.markdownTextFunction = this.settings.useMarkdownTextFunction ? this.markdownTextFunction = this.settings.useMarkdownTextFunction ?
parseFunction(this.settings.markdownTextFunction, ['data', 'ctx']) : null; parseTbFunction(this.ctx.http, this.settings.markdownTextFunction, ['data', 'ctx']) : of(null);
let cssString = this.settings.markdownCss; let cssString = this.settings.markdownCss;
if (isNotEmptyStr(cssString)) { if (isNotEmptyStr(cssString)) {
const cssParser = new cssjs(); const cssParser = new cssjs();
@ -126,8 +129,19 @@ export class MarkdownWidgetComponent extends PageComponent implements OnInit {
initialData = []; initialData = [];
} }
const data = formattedDataFormDatasourceData(initialData); const data = formattedDataFormDatasourceData(initialData);
let markdownText = this.settings.useMarkdownTextFunction ? let markdownText = this.settings.useMarkdownTextFunction ?
safeExecute(this.markdownTextFunction, [data, this.ctx]) : this.settings.markdownTextPattern; this.markdownTextFunction.pipe(map(markdownTextFunction => safeExecuteTbFunction(markdownTextFunction, [data, this.ctx]))) : this.settings.markdownTextPattern;
if (typeof markdownText === 'string') {
this.updateMarkdownText(markdownText, data);
} else {
markdownText.subscribe((text) => {
this.updateMarkdownText(text, data);
});
}
}
private updateMarkdownText(markdownText: string, data: FormattedData[]) {
const allData: FormattedData = flatDataWithoutOverride(data); const allData: FormattedData = flatDataWithoutOverride(data);
markdownText = createLabelFromPattern(markdownText, allData); markdownText = createLabelFromPattern(markdownText, allData);
if (this.markdownText !== markdownText) { if (this.markdownText !== markdownText) {

31
ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts

@ -27,14 +27,18 @@ import {
formattedDataFormDatasourceData, formattedDataFormDatasourceData,
isNumber, isNumber,
isObject, isObject,
parseFunction, parseTbFunction,
safeExecute, unwrapModule safeExecuteTbFunction,
unwrapModule
} from '@core/utils'; } from '@core/utils';
import { CompiledTbFunction, TbFunction } from '@shared/models/js-function.models';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
interface QrCodeWidgetSettings { interface QrCodeWidgetSettings {
qrCodeTextPattern: string; qrCodeTextPattern: string;
useQrCodeTextFunction: boolean; useQrCodeTextFunction: boolean;
qrCodeTextFunction: string; qrCodeTextFunction: TbFunction;
} }
type QrCodeTextFunction = (data: FormattedData[]) => string; type QrCodeTextFunction = (data: FormattedData[]) => string;
@ -47,7 +51,7 @@ type QrCodeTextFunction = (data: FormattedData[]) => string;
export class QrCodeWidgetComponent extends PageComponent implements OnInit, AfterViewInit { export class QrCodeWidgetComponent extends PageComponent implements OnInit, AfterViewInit {
settings: QrCodeWidgetSettings; settings: QrCodeWidgetSettings;
qrCodeTextFunction: QrCodeTextFunction; qrCodeTextFunction: Observable<CompiledTbFunction<QrCodeTextFunction>>;
@Input() @Input()
ctx: WidgetContext; ctx: WidgetContext;
@ -68,7 +72,7 @@ export class QrCodeWidgetComponent extends PageComponent implements OnInit, Afte
ngOnInit(): void { ngOnInit(): void {
this.ctx.$scope.qrCodeWidget = this; this.ctx.$scope.qrCodeWidget = this;
this.settings = this.ctx.settings; this.settings = this.ctx.settings;
this.qrCodeTextFunction = this.settings.useQrCodeTextFunction ? parseFunction(this.settings.qrCodeTextFunction, ['data']) : null; this.qrCodeTextFunction = this.settings.useQrCodeTextFunction ? parseTbFunction(this.ctx.http, this.settings.qrCodeTextFunction, ['data']) : null;
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
@ -81,7 +85,6 @@ export class QrCodeWidgetComponent extends PageComponent implements OnInit, Afte
public onDataUpdated() { public onDataUpdated() {
let initialData: DatasourceData[]; let initialData: DatasourceData[];
let qrCodeText: string;
if (this.ctx.data?.length) { if (this.ctx.data?.length) {
initialData = this.ctx.data; initialData = this.ctx.data;
} else if (this.ctx.datasources?.length) { } else if (this.ctx.datasources?.length) {
@ -100,13 +103,19 @@ export class QrCodeWidgetComponent extends PageComponent implements OnInit, Afte
} }
const data = formattedDataFormDatasourceData(initialData); const data = formattedDataFormDatasourceData(initialData);
const pattern = this.settings.useQrCodeTextFunction ? const pattern = this.settings.useQrCodeTextFunction ?
safeExecute(this.qrCodeTextFunction, [data]) : this.settings.qrCodeTextPattern; this.qrCodeTextFunction.pipe(map(qrCodeTextFunction => safeExecuteTbFunction(qrCodeTextFunction, [data]))) : this.settings.qrCodeTextPattern;
const allData: FormattedData = flatDataWithoutOverride(data); if (typeof pattern === 'string') {
qrCodeText = createLabelFromPattern(pattern, allData); this.updateQrCodeText(pattern, data);
this.updateQrCodeText(qrCodeText); } else {
pattern.subscribe((text) => {
this.updateQrCodeText(text, data);
});
}
} }
private updateQrCodeText(newQrCodeText: string): void { private updateQrCodeText(pattern: string, data: FormattedData[]): void {
const allData: FormattedData = flatDataWithoutOverride(data);
const newQrCodeText = createLabelFromPattern(pattern, allData);
if (this.qrCodeText !== newQrCodeText) { if (this.qrCodeText !== newQrCodeText) {
this.qrCodeText = newQrCodeText; this.qrCodeText = newQrCodeText;
if (!(isObject(newQrCodeText) || isNumber(newQrCodeText))) { if (!(isObject(newQrCodeText) || isNumber(newQrCodeText))) {

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.html

@ -75,6 +75,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="cellStyleFunction" formControlName="cellStyleFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'alarm', 'ctx']" [functionArgs]="['value', 'alarm', 'ctx']"
functionTitle="{{ 'widgets.table.cell-style-function' | translate }}" functionTitle="{{ 'widgets.table.cell-style-function' | translate }}"
@ -98,6 +99,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="cellContentFunction" formControlName="cellContentFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'alarm', 'ctx']" [functionArgs]="['value', 'alarm', 'ctx']"
functionTitle="{{ 'widgets.table.cell-content-function' | translate }}" functionTitle="{{ 'widgets.table.cell-content-function' | translate }}"

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html

@ -117,6 +117,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="rowStyleFunction" formControlName="rowStyleFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['alarm', 'ctx']" [functionArgs]="['alarm', 'ctx']"
functionTitle="{{ 'widgets.table.row-style-function' | translate }}" functionTitle="{{ 'widgets.table.row-style-function' | translate }}"

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/markdown-widget-settings.component.html

@ -21,6 +21,7 @@
</mat-slide-toggle> </mat-slide-toggle>
<tb-js-func [class.!hidden]="!markdownWidgetSettingsForm.get('useMarkdownTextFunction').value" <tb-js-func [class.!hidden]="!markdownWidgetSettingsForm.get('useMarkdownTextFunction').value"
formControlName="markdownTextFunction" formControlName="markdownTextFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['data', 'ctx']" [functionArgs]="['data', 'ctx']"
functionTitle="{{ 'widgets.markdown.markdown-text-function' | translate }}" functionTitle="{{ 'widgets.markdown.markdown-text-function' | translate }}"

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/qrcode-widget-settings.component.html

@ -30,6 +30,7 @@
<tb-js-func required <tb-js-func required
[class.!hidden]="!qrCodeWidgetSettingsForm.get('useQrCodeTextFunction').value" [class.!hidden]="!qrCodeWidgetSettingsForm.get('useQrCodeTextFunction').value"
formControlName="qrCodeTextFunction" formControlName="qrCodeTextFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['data']" [functionArgs]="['data']"
functionTitle="{{ 'widgets.qr-code.qr-code-text-function' | translate }}" functionTitle="{{ 'widgets.qr-code.qr-code-text-function' | translate }}"

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html

@ -63,6 +63,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="cellStyleFunction" formControlName="cellStyleFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'rowData', 'ctx']" [functionArgs]="['value', 'rowData', 'ctx']"
functionTitle="{{ 'widgets.table.cell-style-function' | translate }}" functionTitle="{{ 'widgets.table.cell-style-function' | translate }}"
@ -86,6 +87,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="cellContentFunction" formControlName="cellContentFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'rowData', 'ctx']" [functionArgs]="['value', 'rowData', 'ctx']"
functionTitle="{{ 'widgets.table.cell-content-function' | translate }}" functionTitle="{{ 'widgets.table.cell-content-function' | translate }}"

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html

@ -72,6 +72,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="cellStyleFunction" formControlName="cellStyleFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'rowData', 'ctx']" [functionArgs]="['value', 'rowData', 'ctx']"
functionTitle="{{ 'widgets.table.cell-style-function' | translate }}" functionTitle="{{ 'widgets.table.cell-style-function' | translate }}"
@ -95,6 +96,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="cellContentFunction" formControlName="cellContentFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'rowData', 'ctx']" [functionArgs]="['value', 'rowData', 'ctx']"
functionTitle="{{ 'widgets.table.cell-content-function' | translate }}" functionTitle="{{ 'widgets.table.cell-content-function' | translate }}"

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html

@ -93,6 +93,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="rowStyleFunction" formControlName="rowStyleFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['rowData', 'ctx']" [functionArgs]="['rowData', 'ctx']"
functionTitle="{{ 'widgets.table.row-style-function' | translate }}" functionTitle="{{ 'widgets.table.row-style-function' | translate }}"

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html

@ -51,6 +51,7 @@
<tb-js-func <tb-js-func
[(ngModel)]="action.customFunction" [(ngModel)]="action.customFunction"
(ngModelChange)="onActionUpdated()" (ngModelChange)="onActionUpdated()"
withModules
[fillHeight]="true" [fillHeight]="true"
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel']" [functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel']"
[disableUndefinedCheck]="true" [disableUndefinedCheck]="true"

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html

@ -95,6 +95,7 @@
<tb-js-func <tb-js-func
[(ngModel)]="action.customFunction" [(ngModel)]="action.customFunction"
(ngModelChange)="notifyActionUpdated()" (ngModelChange)="notifyActionUpdated()"
withModules
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel']" [functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel']"
[disableUndefinedCheck]="true" [disableUndefinedCheck]="true"
[validationArgs]="[]" [validationArgs]="[]"

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action.models.ts

@ -63,8 +63,7 @@ const customActionCompletions: TbEditorCompletions = {
type: 'string', type: 'string',
description: 'Label of the entity for which the action was triggered.' description: 'Label of the entity for which the action was triggered.'
} }
}, }
...serviceCompletions
}; };
const customPrettyActionCompletions: TbEditorCompletions = { const customPrettyActionCompletions: TbEditorCompletions = {

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html

@ -237,6 +237,7 @@
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[validationArgs]="[]" [validationArgs]="[]"
[editorCompleter]="customActionEditorCompleter" [editorCompleter]="customActionEditorCompleter"
withModules
helpId="widget/action/custom_action_fn" helpId="widget/action/custom_action_fn"
></tb-js-func> ></tb-js-func>
</ng-template> </ng-template>

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html

@ -75,6 +75,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="cellStyleFunction" formControlName="cellStyleFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'entity', 'ctx']" [functionArgs]="['value', 'entity', 'ctx']"
functionTitle="{{ 'widgets.table.cell-style-function' | translate }}" functionTitle="{{ 'widgets.table.cell-style-function' | translate }}"
@ -98,6 +99,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="cellContentFunction" formControlName="cellContentFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['value', 'entity', 'ctx']" [functionArgs]="['value', 'entity', 'ctx']"
functionTitle="{{ 'widgets.table.cell-content-function' | translate }}" functionTitle="{{ 'widgets.table.cell-content-function' | translate }}"

1
ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html

@ -114,6 +114,7 @@
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<tb-js-func required <tb-js-func required
formControlName="rowStyleFunction" formControlName="rowStyleFunction"
withModules
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[functionArgs]="['entity', 'ctx']" [functionArgs]="['entity', 'ctx']"
functionTitle="{{ 'widgets.table.row-style-function' | translate }}" functionTitle="{{ 'widgets.table.row-style-function' | translate }}"

216
ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts

@ -26,6 +26,14 @@ import { WidgetContext } from '@home/models/widget-component.models';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import {
CompiledTbFunction,
compileTbFunction,
isNotEmptyTbFunction,
TbFunction
} from '@shared/models/js-function.models';
import { forkJoin, Observable, of, ReplaySubject } from 'rxjs';
import { catchError, map, share } from 'rxjs/operators';
type ColumnVisibilityOptions = 'visible' | 'hidden' | 'hidden-mobile'; type ColumnVisibilityOptions = 'visible' | 'hidden' | 'hidden-mobile';
@ -40,7 +48,7 @@ export interface TableWidgetSettings {
displayPagination: boolean; displayPagination: boolean;
defaultPageSize: number; defaultPageSize: number;
useRowStyleFunction: boolean; useRowStyleFunction: boolean;
rowStyleFunction?: string; rowStyleFunction?: TbFunction;
reserveSpaceForHiddenAction?: boolean; reserveSpaceForHiddenAction?: boolean;
} }
@ -48,9 +56,9 @@ export interface TableWidgetDataKeySettings {
customTitle?: string; customTitle?: string;
columnWidth?: string; columnWidth?: string;
useCellStyleFunction: boolean; useCellStyleFunction: boolean;
cellStyleFunction?: string; cellStyleFunction?: TbFunction;
useCellContentFunction: boolean; useCellContentFunction: boolean;
cellContentFunction?: string; cellContentFunction?: TbFunction;
defaultColumnVisibility?: ColumnVisibilityOptions; defaultColumnVisibility?: ColumnVisibilityOptions;
columnSelectionToDisplay?: ColumnSelectionOptions; columnSelectionToDisplay?: ColumnSelectionOptions;
} }
@ -59,7 +67,7 @@ export type ShowCellButtonActionFunction = (ctx: WidgetContext, data: EntityData
export interface TableCellButtonActionDescriptor extends WidgetActionDescriptor { export interface TableCellButtonActionDescriptor extends WidgetActionDescriptor {
useShowActionCellButtonFunction: boolean; useShowActionCellButtonFunction: boolean;
showActionCellButtonFunction: ShowCellButtonActionFunction; showActionCellButtonFunction: CompiledTbFunction<ShowCellButtonActionFunction>;
} }
export interface EntityData { export interface EntityData {
@ -88,9 +96,13 @@ export interface DisplayColumn {
export type CellContentFunction = (...args: any[]) => string; export type CellContentFunction = (...args: any[]) => string;
export interface CellContentInfo { export interface CellContentFunctionInfo {
useCellContentFunction: boolean; useCellContentFunction: boolean;
cellContentFunction?: CellContentFunction; cellContentFunction?: CompiledTbFunction<CellContentFunction>;
}
export interface CellContentInfo {
contentFunction: Observable<CellContentFunctionInfo>;
units?: string; units?: string;
decimals?: number; decimals?: number;
} }
@ -99,14 +111,14 @@ export type CellStyleFunction = (...args: any[]) => any;
export interface CellStyleInfo { export interface CellStyleInfo {
useCellStyleFunction: boolean; useCellStyleFunction: boolean;
cellStyleFunction?: CellStyleFunction; cellStyleFunction?: CompiledTbFunction<CellStyleFunction>;
} }
export type RowStyleFunction = (...args: any[]) => any; export type RowStyleFunction = (...args: any[]) => any;
export interface RowStyleInfo { export interface RowStyleInfo {
useRowStyleFunction: boolean; useRowStyleFunction: boolean;
rowStyleFunction?: RowStyleFunction; rowStyleFunction?: CompiledTbFunction<RowStyleFunction>;
} }
@ -229,67 +241,113 @@ export function getAlarmValue(alarm: AlarmDataInfo, key: EntityColumn) {
} }
} }
export function getRowStyleInfo(settings: TableWidgetSettings, ...args: string[]): RowStyleInfo { export function getRowStyleInfo(widgetContext: WidgetContext, settings: TableWidgetSettings, ...args: string[]): Observable<RowStyleInfo> {
let rowStyleFunction: RowStyleFunction = null; let rowStyleInfo$: Observable<RowStyleInfo>;
let useRowStyleFunction = false; if (settings.useRowStyleFunction === true && isNotEmptyTbFunction(settings.rowStyleFunction)) {
rowStyleInfo$ = compileTbFunction<RowStyleFunction>(widgetContext.http, settings.rowStyleFunction, ...args).pipe(
if (settings.useRowStyleFunction === true) { catchError(() => { return of(null) }),
if (isDefined(settings.rowStyleFunction) && settings.rowStyleFunction.length > 0) { map((rowStyleFunction) => {
try { if (!rowStyleFunction) {
rowStyleFunction = new Function(...args, settings.rowStyleFunction) as RowStyleFunction; return {
useRowStyleFunction = true; useRowStyleFunction: false,
} catch (e) { rowStyleFunction: null
rowStyleFunction = null; }
useRowStyleFunction = false; } else {
} return {
} useRowStyleFunction: true,
rowStyleFunction
}
}
})
);
} else {
rowStyleInfo$ = of({
useRowStyleFunction: false,
rowStyleFunction: null
});
} }
return { return rowStyleInfo$.pipe(
useRowStyleFunction, share({
rowStyleFunction connector: () => new ReplaySubject(1),
}; resetOnError: false,
} resetOnComplete: false,
resetOnRefCountZero: false
export function getCellStyleInfo(keySettings: TableWidgetDataKeySettings, ...args: string[]): CellStyleInfo { })
let cellStyleFunction: CellStyleFunction = null; );
let useCellStyleFunction = false; }
if (keySettings.useCellStyleFunction === true) { export function getCellStyleInfo(widgetContext: WidgetContext, keySettings: TableWidgetDataKeySettings, ...args: string[]): Observable<CellStyleInfo> {
if (isDefined(keySettings.cellStyleFunction) && keySettings.cellStyleFunction.length > 0) { let cellStyleInfo$: Observable<CellStyleInfo>;
try { if (keySettings.useCellStyleFunction === true && isNotEmptyTbFunction(keySettings.cellStyleFunction)) {
cellStyleFunction = new Function(...args, keySettings.cellStyleFunction) as CellStyleFunction; cellStyleInfo$ = compileTbFunction<CellStyleFunction>(widgetContext.http, keySettings.cellStyleFunction, ...args).pipe(
useCellStyleFunction = true; catchError(() => { return of(null) }),
} catch (e) { map((cellStyleFunction) => {
cellStyleFunction = null; if (!cellStyleFunction) {
useCellStyleFunction = false; return {
useCellStyleFunction: false,
cellStyleFunction: null
}
} else {
return {
useCellStyleFunction: true,
cellStyleFunction
}
}
})
);
} else {
cellStyleInfo$ = of(
{
useCellStyleFunction: false,
cellStyleFunction: null
} }
} )
} }
return { return cellStyleInfo$.pipe(
useCellStyleFunction, share({
cellStyleFunction connector: () => new ReplaySubject(1),
}; resetOnError: false,
} resetOnComplete: false,
resetOnRefCountZero: false
export function getCellContentInfo(keySettings: TableWidgetDataKeySettings, ...args: string[]): CellContentInfo { })
let cellContentFunction: CellContentFunction = null; );
let useCellContentFunction = false; }
if (keySettings.useCellContentFunction === true) { export function getCellContentFunctionInfo(widgetContext: WidgetContext, keySettings: TableWidgetDataKeySettings, ...args: string[]): Observable<CellContentFunctionInfo> {
if (isDefined(keySettings.cellContentFunction) && keySettings.cellContentFunction.length > 0) { let cellContentFunctionInfo$: Observable<CellContentFunctionInfo>;
try { if (keySettings.useCellContentFunction === true && isNotEmptyTbFunction(keySettings.cellContentFunction)) {
cellContentFunction = new Function(...args, keySettings.cellContentFunction) as CellContentFunction; cellContentFunctionInfo$ = compileTbFunction<CellContentFunction>(widgetContext.http, keySettings.cellContentFunction, ...args).pipe(
useCellContentFunction = true; catchError(() => { return of(null) }),
} catch (e) { map((cellContentFunction) => {
cellContentFunction = null; if (!cellContentFunction) {
useCellContentFunction = false; return {
useCellContentFunction: false,
cellContentFunction: null
}
} else {
return {
useCellContentFunction: true,
cellContentFunction
}
}
})
);
} else {
cellContentFunctionInfo$ = of(
{
useCellContentFunction: false,
cellContentFunction: null
} }
} )
} }
return { return cellContentFunctionInfo$.pipe(
cellContentFunction, share({
useCellContentFunction connector: () => new ReplaySubject(1),
}; resetOnError: false,
resetOnComplete: false,
resetOnRefCountZero: false
})
);
} }
export function getColumnWidth(keySettings: TableWidgetDataKeySettings): string { export function getColumnWidth(keySettings: TableWidgetDataKeySettings): string {
@ -314,20 +372,26 @@ export function getColumnSelectionAvailability(keySettings: TableWidgetDataKeySe
return !(isDefined(keySettings.columnSelectionToDisplay) && keySettings.columnSelectionToDisplay === 'disabled'); return !(isDefined(keySettings.columnSelectionToDisplay) && keySettings.columnSelectionToDisplay === 'disabled');
} }
export function getTableCellButtonActions(widgetContext: WidgetContext): TableCellButtonActionDescriptor[] { export function getTableCellButtonActions(widgetContext: WidgetContext): Observable<TableCellButtonActionDescriptor[]> {
return widgetContext.actionsApi.getActionDescriptors('actionCellButton').map(descriptor => { const actions$ = widgetContext.actionsApi.getActionDescriptors('actionCellButton').map(descriptor => {
let useShowActionCellButtonFunction = descriptor.useShowWidgetActionFunction || false; let useShowActionCellButtonFunction = descriptor.useShowWidgetActionFunction || false;
let showActionCellButtonFunction: ShowCellButtonActionFunction = null; let showActionCellButtonFunction$: Observable<CompiledTbFunction<ShowCellButtonActionFunction>>;
if (useShowActionCellButtonFunction && isNotEmptyStr(descriptor.showWidgetActionFunction)) { if (useShowActionCellButtonFunction && isNotEmptyTbFunction(descriptor.showWidgetActionFunction)) {
try { showActionCellButtonFunction$ = compileTbFunction(widgetContext.http, descriptor.showWidgetActionFunction, 'widgetContext', 'data');
showActionCellButtonFunction = } else {
new Function('widgetContext', 'data', descriptor.showWidgetActionFunction) as ShowCellButtonActionFunction; showActionCellButtonFunction$ = of(null);
} catch (e) {
useShowActionCellButtonFunction = false;
}
} }
return {...descriptor, showActionCellButtonFunction, useShowActionCellButtonFunction}; return showActionCellButtonFunction$.pipe(
catchError(() => { return of(null) }),
map(showActionCellButtonFunction => {
if (!showActionCellButtonFunction) {
useShowActionCellButtonFunction = false;
}
return {...descriptor, showActionCellButtonFunction, useShowActionCellButtonFunction};
})
);
}); });
return actions$.length ? forkJoin(actions$) : of([]);
} }
export function checkHasActions(cellButtonActions: TableCellButtonActionDescriptor[]): boolean { export function checkHasActions(cellButtonActions: TableCellButtonActionDescriptor[]): boolean {
@ -348,7 +412,7 @@ function filterTableCellButtonAction(widgetContext: WidgetContext,
action: TableCellButtonActionDescriptor, data: EntityData | AlarmDataInfo | FormattedData): boolean { action: TableCellButtonActionDescriptor, data: EntityData | AlarmDataInfo | FormattedData): boolean {
if (action.useShowActionCellButtonFunction) { if (action.useShowActionCellButtonFunction) {
try { try {
return action.showActionCellButtonFunction(widgetContext, data); return action.showActionCellButtonFunction.execute(widgetContext, data);
} catch (e) { } catch (e) {
console.warn('Failed to execute showActionCellButtonFunction', e); console.warn('Failed to execute showActionCellButtonFunction', e);
return false; return false;

12
ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html

@ -49,15 +49,15 @@
<ng-container *ngIf="showTimestamp" [matColumnDef]="'0'"> <ng-container *ngIf="showTimestamp" [matColumnDef]="'0'">
<mat-header-cell *matHeaderCellDef mat-sort-header>Timestamp</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Timestamp</mat-header-cell>
<mat-cell *matCellDef="let row; let rowIndex = index" <mat-cell *matCellDef="let row; let rowIndex = index"
[innerHTML]="cellContent(source, null, 0, row, row[0], rowIndex)" [innerHTML]="cellContent(source, null, 0, row, row[0], rowIndex) | async"
[style]="cellStyle(source, null, 0, row, row[0], rowIndex)"> [style]="cellStyle(source, null, 0, row, row[0], rowIndex) | async">
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container [matColumnDef]="h.index + ''" *ngFor="let h of source.header; trackBy: trackByColumnIndex;"> <ng-container [matColumnDef]="h.index + ''" *ngFor="let h of source.header; trackBy: trackByColumnIndex;">
<mat-header-cell *matHeaderCellDef mat-sort-header [disabled]="!h.sortable"> {{ h.dataKey.label }} </mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header [disabled]="!h.sortable"> {{ h.dataKey.label }} </mat-header-cell>
<mat-cell *matCellDef="let row; let rowIndex = index" <mat-cell *matCellDef="let row; let rowIndex = index"
[innerHTML]="cellContent(source, h, h.index, row, row[h.index], rowIndex)" [innerHTML]="cellContent(source, h, h.index, row, row[h.index], rowIndex) | async"
[style]="cellStyle(source, h, h.index, row, row[h.index], rowIndex)"> [style]="cellStyle(source, h, h.index, row, row[h.index], rowIndex) | async">
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="actions" [stickyEnd]="enableStickyAction"> <ng-container matColumnDef="actions" [stickyEnd]="enableStickyAction">
@ -70,7 +70,7 @@
</div> </div>
</ng-container> </ng-container>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let row; let rowIndex = index" [style]="rowStyle(source, row, rowIndex)"> <mat-cell *matCellDef="let row; let rowIndex = index" [style]="rowStyle(source, row, rowIndex) | async">
<ng-container *ngIf="source.timeseriesDatasource.countCellButtonAction"> <ng-container *ngIf="source.timeseriesDatasource.countCellButtonAction">
<div [class.lt-lg:!hidden]="showCellActionsMenu && source.timeseriesDatasource.countCellButtonAction !== 1" class="flex flex-row items-stretch justify-end" <div [class.lt-lg:!hidden]="showCellActionsMenu && source.timeseriesDatasource.countCellButtonAction !== 1" class="flex flex-row items-stretch justify-end"
[style.min-width]="(source.timeseriesDatasource.countCellButtonAction * 40) + 'px'"> [style.min-width]="(source.timeseriesDatasource.countCellButtonAction * 40) + 'px'">
@ -110,7 +110,7 @@
<mat-header-row *matHeaderRowDef="source.displayedColumns; sticky: enableStickyHeader"></mat-header-row> <mat-header-row *matHeaderRowDef="source.displayedColumns; sticky: enableStickyHeader"></mat-header-row>
<mat-row [class.tb-pointer]="hasRowAction" <mat-row [class.tb-pointer]="hasRowAction"
*matRowDef="let row; columns: source.displayedColumns; let rowIndex = index" *matRowDef="let row; columns: source.displayedColumns; let rowIndex = index"
[style]="rowStyle(source, row, rowIndex)" [style]="rowStyle(source, row, rowIndex) | async"
(click)="onRowClick($event, row)"></mat-row> (click)="onRowClick($event, row)"></mat-row>
</table> </table>
<span [class.!hidden]="(source.timeseriesDatasource.isEmpty() | async) === false" <span [class.!hidden]="(source.timeseriesDatasource.isEmpty() | async) === false"

397
ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts

@ -20,7 +20,8 @@ import {
Component, Component,
ElementRef, ElementRef,
Injector, Injector,
Input, NgZone, Input,
NgZone,
OnDestroy, OnDestroy,
OnInit, OnInit,
QueryList, QueryList,
@ -58,7 +59,17 @@ import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/s
import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, fromEvent, merge, Observable, of, Subject, Subscription } from 'rxjs'; import { BehaviorSubject, fromEvent, merge, Observable, of, Subject, Subscription } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { catchError, debounceTime, distinctUntilChanged, map, skip, startWith, takeUntil } from 'rxjs/operators'; import {
catchError,
debounceTime,
distinctUntilChanged,
map,
skip,
startWith,
switchMap,
takeUntil,
tap
} from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@ -68,7 +79,7 @@ import {
checkHasActions, checkHasActions,
constructTableCssString, constructTableCssString,
DisplayColumn, DisplayColumn,
getCellContentInfo, getCellContentFunctionInfo,
getCellStyleInfo, getCellStyleInfo,
getColumnDefaultVisibility, getColumnDefaultVisibility,
getColumnSelectionAvailability, getColumnSelectionAvailability,
@ -121,7 +132,7 @@ interface TimeseriesHeader {
show: boolean; show: boolean;
columnDefaultVisibility?: boolean; columnDefaultVisibility?: boolean;
columnSelectionAvailability?: boolean; columnSelectionAvailability?: boolean;
styleInfo: CellStyleInfo; styleInfo: Observable<CellStyleInfo>;
contentInfo: CellContentInfo; contentInfo: CellContentInfo;
order?: number; order?: number;
} }
@ -190,7 +201,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
private displayedColumns: Array<DisplayColumn[]> = []; private displayedColumns: Array<DisplayColumn[]> = [];
private rowStylesInfo: RowStyleInfo; private rowStylesInfo: Observable<RowStyleInfo>;
private subscriptions: Subscription[] = []; private subscriptions: Subscription[] = [];
private widgetTimewindowChanged$: Subscription; private widgetTimewindowChanged$: Subscription;
@ -338,7 +349,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
this.dateFormatFilter = isDefined(this.settings.dateFormat?.format) ? this.settings.dateFormat?.format : 'yyyy-MM-dd HH:mm:ss'; this.dateFormatFilter = isDefined(this.settings.dateFormat?.format) ? this.settings.dateFormat?.format : 'yyyy-MM-dd HH:mm:ss';
} }
this.rowStylesInfo = getRowStyleInfo(this.settings, 'rowData, ctx'); this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'rowData, ctx');
const pageSize = this.settings.defaultPageSize; const pageSize = this.settings.defaultPageSize;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
@ -509,12 +520,15 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
dataKeys.forEach((dataKey, index) => { dataKeys.forEach((dataKey, index) => {
const sortable = !dataKey.usePostProcessing; const sortable = !dataKey.usePostProcessing;
const keySettings: TableWidgetDataKeySettings = dataKey.settings; const keySettings: TableWidgetDataKeySettings = dataKey.settings;
const styleInfo = getCellStyleInfo(keySettings, 'value, rowData, ctx'); const styleInfo = getCellStyleInfo(this.ctx, keySettings, 'value, rowData, ctx');
const contentInfo = getCellContentInfo(keySettings, 'value, rowData, ctx'); const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, rowData, ctx');
const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx); const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx);
const columnSelectionAvailability = getColumnSelectionAvailability(keySettings); const columnSelectionAvailability = getColumnSelectionAvailability(keySettings);
contentInfo.units = dataKey.units; const contentInfo: CellContentInfo = {
contentInfo.decimals = dataKey.decimals; contentFunction: contentFunctionInfo,
units: dataKey.units,
decimals: dataKey.decimals
};
header.push({ header.push({
index: index + 1, index: index + 1,
dataKey, dataKey,
@ -532,12 +546,15 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
const index = dataKeys.length + latestIndex; const index = dataKeys.length + latestIndex;
const sortable = !dataKey.usePostProcessing; const sortable = !dataKey.usePostProcessing;
const keySettings: TimeseriesWidgetLatestDataKeySettings = dataKey.settings; const keySettings: TimeseriesWidgetLatestDataKeySettings = dataKey.settings;
const styleInfo = getCellStyleInfo(keySettings, 'value, rowData, ctx'); const styleInfo = getCellStyleInfo(this.ctx, keySettings, 'value, rowData, ctx');
const contentInfo = getCellContentInfo(keySettings, 'value, rowData, ctx'); const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, rowData, ctx');
const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx); const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx);
const columnSelectionAvailability = getColumnSelectionAvailability(keySettings); const columnSelectionAvailability = getColumnSelectionAvailability(keySettings);
contentInfo.units = dataKey.units; const contentInfo: CellContentInfo = {
contentInfo.decimals = dataKey.decimals; contentFunction: contentFunctionInfo,
units: dataKey.units,
decimals: dataKey.decimals
};
header.push({ header.push({
index: index + 1, index: index + 1,
dataKey, dataKey,
@ -648,111 +665,143 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
return source.datasource.entityId; return source.datasource.entityId;
} }
public rowStyle(source: TimeseriesTableSource, row: TimeseriesRow, index: number): any { public rowStyle(source: TimeseriesTableSource, row: TimeseriesRow, index: number): Observable<any> {
let style$: Observable<any>;
let res = this.rowStyleCache[index]; let res = this.rowStyleCache[index];
if (!res) { if (!res) {
res = {}; style$ = this.rowStylesInfo.pipe(
if (this.rowStylesInfo.useRowStyleFunction && this.rowStylesInfo.rowStyleFunction) { map(styleInfo => {
try { if (styleInfo.useRowStyleFunction && styleInfo.rowStyleFunction) {
const rowData = source.rowDataTemplate; const rowData = source.rowDataTemplate;
rowData.Timestamp = row[0]; rowData.Timestamp = row[0];
source.header.forEach((headerInfo) => { source.header.forEach((headerInfo) => {
rowData[headerInfo.dataKey.label] = row[headerInfo.index]; rowData[headerInfo.dataKey.label] = row[headerInfo.index];
}); });
res = this.rowStylesInfo.rowStyleFunction(rowData, this.ctx); const style = styleInfo.rowStyleFunction.execute(rowData, this.ctx);
if (!isObject(res)) { if (!isObject(style)) {
throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); throw new TypeError(`${style === null ? 'null' : typeof style} instead of style object`);
} }
if (Array.isArray(res)) { if (Array.isArray(style)) {
throw new TypeError(`Array instead of style object`); throw new TypeError(`Array instead of style object`);
}
return style;
} else {
return {};
} }
} catch (e) { }),
res = {}; catchError(e => {
console.warn(`Row style function in widget ` + console.warn(`Row style function in widget ` +
`'${this.ctx.widgetConfig.title}' returns '${e}'. Please check your row style function.`); `'${this.ctx.widgetConfig.title}' returns '${e}'. Please check your row style function.`);
} return of({});
} })
this.rowStyleCache[index] = res; );
style$ = style$.pipe(
tap((style) => {
this.rowStyleCache[index] = style;
})
);
} else {
style$ = of(res);
} }
return res; return style$;
} }
public cellStyle(source: TimeseriesTableSource, header: TimeseriesHeader, public cellStyle(source: TimeseriesTableSource, header: TimeseriesHeader,
index: number, row: TimeseriesRow, value: any, rowIndex: number): any { index: number, row: TimeseriesRow, value: any, rowIndex: number): Observable<any> {
let style$: Observable<any>;
const cacheIndex = rowIndex * (source.header.length + 1) + index; const cacheIndex = rowIndex * (source.header.length + 1) + index;
let res = this.cellStyleCache[cacheIndex]; let res = this.cellStyleCache[cacheIndex];
if (!res) { if (!res) {
res = {};
if (index > 0) { if (index > 0) {
const styleInfo = header.styleInfo; style$ = header.styleInfo.pipe(
if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { map(styleInfo => {
try { if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {
const rowData = source.rowDataTemplate; const rowData = source.rowDataTemplate;
rowData.Timestamp = row[0]; rowData.Timestamp = row[0];
source.header.forEach((headerInfo) => { source.header.forEach((headerInfo) => {
rowData[headerInfo.dataKey.label] = row[headerInfo.index]; rowData[headerInfo.dataKey.label] = row[headerInfo.index];
}); });
res = styleInfo.cellStyleFunction(value, rowData, this.ctx); const style = styleInfo.cellStyleFunction.execute(value, rowData, this.ctx);
if (!isObject(res)) { if (!isObject(style)) {
throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); throw new TypeError(`${style === null ? 'null' : typeof style} instead of style object`);
} }
if (Array.isArray(res)) { if (Array.isArray(style)) {
throw new TypeError(`Array instead of style object`); throw new TypeError(`Array instead of style object`);
}
return style;
} else {
return {};
} }
} catch (e) { }),
res = {}; catchError(e => {
console.warn(`Cell style function for data key '${source.header[index - 1].dataKey.label}' in widget ` + console.warn(`Cell style function for data key '${source.header[index - 1].dataKey.label}' in widget ` +
`'${this.ctx.widgetConfig.title}' returns '${e}'. Please check your cell style function.`); `'${this.ctx.widgetConfig.title}' returns '${e}'. Please check your cell style function.`);
} return of({});
} })
);
} else {
style$ = of({});
} }
this.cellStyleCache[cacheIndex] = res; style$ = style$.pipe(
tap((style) => {
this.cellStyleCache[cacheIndex] = style;
})
);
} else {
style$ = of(res);
} }
return res; return style$;
} }
public cellContent(source: TimeseriesTableSource, header: TimeseriesHeader, public cellContent(source: TimeseriesTableSource, header: TimeseriesHeader,
index: number, row: TimeseriesRow, value: any, rowIndex: number): SafeHtml { index: number, row: TimeseriesRow, value: any, rowIndex: number): Observable<SafeHtml> {
let content$: Observable<SafeHtml>;
const cacheIndex = rowIndex * (source.header.length + 1) + index ; const cacheIndex = rowIndex * (source.header.length + 1) + index ;
let res = this.cellContentCache[cacheIndex]; let res = this.cellContentCache[cacheIndex];
if (isUndefined(res)) { if (isUndefined(res)) {
res = '';
if (index === 0) { if (index === 0) {
res = row.formattedTs; content$ = of(row.formattedTs);
} else { } else {
let content; content$ = header.contentInfo.contentFunction.pipe(
const contentInfo = header.contentInfo; map((contentFunction) => {
if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { let content: any;
try { if (contentFunction.useCellContentFunction && contentFunction.cellContentFunction) {
const rowData = source.rowDataTemplate; try {
rowData.Timestamp = row[0]; const rowData = source.rowDataTemplate;
source.header.forEach((headerInfo) => { rowData.Timestamp = row[0];
rowData[headerInfo.dataKey.label] = row[headerInfo.index]; source.header.forEach((headerInfo) => {
}); rowData[headerInfo.dataKey.label] = row[headerInfo.index];
content = contentInfo.cellContentFunction(value, rowData, this.ctx); });
} catch (e) { content = contentFunction.cellContentFunction.execute(value, rowData, this.ctx);
content = '' + value; } catch (e) {
} content = '' + value;
} else { }
const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals; } else {
const units = contentInfo.units || this.ctx.widgetConfig.units; const decimals = (header.contentInfo.decimals || header.contentInfo.decimals === 0) ? header.contentInfo.decimals : this.ctx.widgetConfig.decimals;
content = this.ctx.utils.formatValue(value, decimals, units, true); const units = header.contentInfo.units || this.ctx.widgetConfig.units;
} content = this.ctx.utils.formatValue(value, decimals, units, true);
}
if (isDefined(content)) { if (isDefined(content)) {
content = this.utils.customTranslation(content, content); content = this.utils.customTranslation(content, content);
switch (typeof content) { switch (typeof content) {
case 'string': case 'string':
res = this.domSanitizer.bypassSecurityTrustHtml(content); content = this.domSanitizer.bypassSecurityTrustHtml(content);
break; break;
default: }
res = content; }
} return content;
} })
);
} }
this.cellContentCache[cacheIndex] = res; content$ = content$.pipe(
tap((content) => {
this.cellContentCache[cacheIndex] = content;
})
);
} else {
content$ = of(res);
} }
return res; return content$;
} }
public onRowClick($event: Event, row: TimeseriesRow) { public onRowClick($event: Event, row: TimeseriesRow) {
@ -828,7 +877,8 @@ class TimeseriesDatasource implements DataSource<TimeseriesRow> {
private reserveSpaceForHiddenAction = true; private reserveSpaceForHiddenAction = true;
private cellButtonActions: TableCellButtonActionDescriptor[]; private cellButtonActions: TableCellButtonActionDescriptor[];
private readonly usedShowCellActionFunction: boolean; private usedShowCellActionFunction: boolean;
private inited = false;
constructor( constructor(
private source: TimeseriesTableSource, private source: TimeseriesTableSource,
@ -837,14 +887,25 @@ class TimeseriesDatasource implements DataSource<TimeseriesRow> {
private datePipe: DatePipe, private datePipe: DatePipe,
private widgetContext: WidgetContext private widgetContext: WidgetContext
) { ) {
this.cellButtonActions = getTableCellButtonActions(widgetContext);
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
if (this.widgetContext.settings.reserveSpaceForHiddenAction) { if (this.widgetContext.settings.reserveSpaceForHiddenAction) {
this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction); this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction);
} }
this.source.timeseriesDatasource = this; this.source.timeseriesDatasource = this;
} }
private init(): Observable<any> {
if (this.inited) {
return of(null);
}
return getTableCellButtonActions(this.widgetContext).pipe(
tap(actions => {
this.cellButtonActions = actions
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
this.inited = true;
})
);
}
connect(collectionViewer: CollectionViewer): Observable<TimeseriesRow[] | ReadonlyArray<TimeseriesRow>> { connect(collectionViewer: CollectionViewer): Observable<TimeseriesRow[] | ReadonlyArray<TimeseriesRow>> {
if (this.rowsSubject.isStopped) { if (this.rowsSubject.isStopped) {
this.rowsSubject.isStopped = false; this.rowsSubject.isStopped = false;
@ -886,68 +947,74 @@ class TimeseriesDatasource implements DataSource<TimeseriesRow> {
} }
private updateSourceData() { private updateSourceData() {
this.source.data = this.convertData(this.source.rawData, this.source.latestRawData); this.convertData(this.source.rawData, this.source.latestRawData).subscribe((data) => {
this.allRowsSubject.next(this.source.data); this.source.data = data;
} this.allRowsSubject.next(this.source.data);
});
private convertData(data: DatasourceData[], latestData: DatasourceData[]): TimeseriesRow[] { }
const rowsMap: {[timestamp: number]: TimeseriesRow} = {};
for (let d = 0; d < data.length; d++) { private convertData(data: DatasourceData[], latestData: DatasourceData[]): Observable<TimeseriesRow[]> {
const columnData = data[d].data; return this.init().pipe(
columnData.forEach((cellData) => { map(() => {
const timestamp = cellData[0]; const rowsMap: {[timestamp: number]: TimeseriesRow} = {};
let row = rowsMap[timestamp]; for (let d = 0; d < data.length; d++) {
if (!row) { const columnData = data[d].data;
row = { columnData.forEach((cellData) => {
formattedTs: this.datePipe.transform(timestamp, this.dateFormatFilter) const timestamp = cellData[0];
}; let row = rowsMap[timestamp];
if (this.cellButtonActions.length) { if (!row) {
if (this.usedShowCellActionFunction) { row = {
const parsedData = formattedDataFormDatasourceData(data, undefined, timestamp); formattedTs: this.datePipe.transform(timestamp, this.dateFormatFilter)
row.actionCellButtons = prepareTableCellButtonActions(this.widgetContext, this.cellButtonActions, };
parsedData[0], this.reserveSpaceForHiddenAction); if (this.cellButtonActions.length) {
row.hasActions = checkHasActions(row.actionCellButtons); if (this.usedShowCellActionFunction) {
} else { const parsedData = formattedDataFormDatasourceData(data, undefined, timestamp);
row.hasActions = true; row.actionCellButtons = prepareTableCellButtonActions(this.widgetContext, this.cellButtonActions,
row.actionCellButtons = this.cellButtonActions; parsedData[0], this.reserveSpaceForHiddenAction);
row.hasActions = checkHasActions(row.actionCellButtons);
} else {
row.hasActions = true;
row.actionCellButtons = this.cellButtonActions;
}
}
row[0] = timestamp;
for (let c = 0; c < (data.length + latestData.length); c++) {
row[c + 1] = undefined;
}
rowsMap[timestamp] = row;
} }
} row[d + 1] = cellData[1];
row[0] = timestamp; });
for (let c = 0; c < (data.length + latestData.length); c++) {
row[c + 1] = undefined;
}
rowsMap[timestamp] = row;
} }
row[d + 1] = cellData[1];
});
}
let rows: TimeseriesRow[] = []; let rows: TimeseriesRow[] = [];
if (this.hideEmptyLines) { if (this.hideEmptyLines) {
for (const t of Object.keys(rowsMap)) { for (const t of Object.keys(rowsMap)) {
let hideLine = true; let hideLine = true;
for (let c = 0; (c < data.length) && hideLine; c++) { for (let c = 0; (c < data.length) && hideLine; c++) {
if (rowsMap[t][c + 1]) { if (rowsMap[t][c + 1]) {
hideLine = false; hideLine = false;
}
}
if (!hideLine) {
rows.push(rowsMap[t]);
}
} }
} else {
rows = Object.keys(rowsMap).map(itm => rowsMap[itm]);
} }
if (!hideLine) { for (let d = 0; d < latestData.length; d++) {
rows.push(rowsMap[t]); const columnData = latestData[d].data;
if (columnData.length) {
const value = columnData[0][1];
rows.forEach((row) => {
row[data.length + d + 1] = value;
});
}
} }
} return rows;
} else { })
rows = Object.keys(rowsMap).map(itm => rowsMap[itm]); );
}
for (let d = 0; d < latestData.length; d++) {
const columnData = latestData[d].data;
if (columnData.length) {
const value = columnData[0][1];
rows.forEach((row) => {
row[data.length + d + 1] = value;
});
}
}
return rows;
} }
isEmpty(): Observable<boolean> { isEmpty(): Observable<boolean> {
@ -963,19 +1030,23 @@ class TimeseriesDatasource implements DataSource<TimeseriesRow> {
} }
private fetchRows(pageLink: PageLink): Observable<PageData<TimeseriesRow>> { private fetchRows(pageLink: PageLink): Observable<PageData<TimeseriesRow>> {
return this.allRows$.pipe( return this.init().pipe(
map((data) => { switchMap(() => {
const fetchData = pageLink.filterData(data); return this.allRows$.pipe(
if (this.cellButtonActions.length) { map((data) => {
let maxCellButtonAction: number; const fetchData = pageLink.filterData(data);
if (this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction) { if (this.cellButtonActions.length) {
maxCellButtonAction = Math.max(...fetchData.data.map(tsRow => tsRow.actionCellButtons.length)); let maxCellButtonAction: number;
} else { if (this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction) {
maxCellButtonAction = this.cellButtonActions.length; maxCellButtonAction = Math.max(...fetchData.data.map(tsRow => tsRow.actionCellButtons.length));
} } else {
this.countCellButtonAction = maxCellButtonAction; maxCellButtonAction = this.cellButtonActions.length;
} }
return fetchData; this.countCellButtonAction = maxCellButtonAction;
}
return fetchData;
})
);
}) })
); );
} }

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

@ -61,6 +61,8 @@ import { HOME_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens';
import { widgetSettingsComponentsMap } from '@home/components/widget/lib/settings/widget-settings.module'; import { widgetSettingsComponentsMap } from '@home/components/widget/lib/settings/widget-settings.module';
import { basicWidgetConfigComponentsMap } from '@home/components/widget/config/basic/basic-widget-config.module'; import { basicWidgetConfigComponentsMap } from '@home/components/widget/config/basic/basic-widget-config.module';
import { IBasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; import { IBasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { compileTbFunction, TbFunction } from '@shared/models/js-function.models';
import { HttpClient } from '@angular/common/http';
@Injectable() @Injectable()
export class WidgetComponentService { export class WidgetComponentService {
@ -82,7 +84,8 @@ export class WidgetComponentService {
private widgetService: WidgetService, private widgetService: WidgetService,
private utils: UtilsService, private utils: UtilsService,
private resources: ResourcesService, private resources: ResourcesService,
private translate: TranslateService) { private translate: TranslateService,
private http: HttpClient) {
this.cssParser.testMode = false; this.cssParser.testMode = false;
@ -288,42 +291,45 @@ export class WidgetComponentService {
private loadWidget(widgetType: WidgetType, widgetInfoSubject: Subject<WidgetInfo>) { private loadWidget(widgetType: WidgetType, widgetInfoSubject: Subject<WidgetInfo>) {
const widgetInfo = toWidgetInfo(widgetType); const widgetInfo = toWidgetInfo(widgetType);
let widgetControllerDescriptor: WidgetControllerDescriptor = null; this.createWidgetControllerDescriptor(widgetInfo).subscribe(
try { {
widgetControllerDescriptor = this.createWidgetControllerDescriptor(widgetInfo); next: widgetControllerDescriptor => {
} catch (e) { const widgetNamespace = `widget-type-${widgetInfo.fullFqn.replace(/\./g, '-')}`;
const details = this.utils.parseException(e); this.loadWidgetResources(widgetInfo, widgetNamespace, [SharedModule, WidgetComponentsModule, this.homeComponentsModule]).subscribe(
const errorMessage = `Failed to compile widget script. \n Error: ${details.message}`; {
this.processWidgetLoadError([errorMessage], widgetInfo.fullFqn, widgetInfoSubject); next: () => {
} if (widgetControllerDescriptor.settingsSchema) {
if (widgetControllerDescriptor) { widgetInfo.typeSettingsSchema = widgetControllerDescriptor.settingsSchema;
const widgetNamespace = `widget-type-${widgetInfo.fullFqn.replace(/\./g, '-')}`; }
this.loadWidgetResources(widgetInfo, widgetNamespace, [SharedModule, WidgetComponentsModule, this.homeComponentsModule]).subscribe( if (widgetControllerDescriptor.dataKeySettingsSchema) {
() => { widgetInfo.typeDataKeySettingsSchema = widgetControllerDescriptor.dataKeySettingsSchema;
if (widgetControllerDescriptor.settingsSchema) { }
widgetInfo.typeSettingsSchema = widgetControllerDescriptor.settingsSchema; if (widgetControllerDescriptor.latestDataKeySettingsSchema) {
} widgetInfo.typeLatestDataKeySettingsSchema = widgetControllerDescriptor.latestDataKeySettingsSchema;
if (widgetControllerDescriptor.dataKeySettingsSchema) { }
widgetInfo.typeDataKeySettingsSchema = widgetControllerDescriptor.dataKeySettingsSchema; widgetInfo.typeParameters = widgetControllerDescriptor.typeParameters;
} widgetInfo.actionSources = widgetControllerDescriptor.actionSources;
if (widgetControllerDescriptor.latestDataKeySettingsSchema) { widgetInfo.widgetTypeFunction = widgetControllerDescriptor.widgetTypeFunction;
widgetInfo.typeLatestDataKeySettingsSchema = widgetControllerDescriptor.latestDataKeySettingsSchema; this.widgetService.putWidgetInfoToCache(widgetInfo);
} if (widgetInfoSubject) {
widgetInfo.typeParameters = widgetControllerDescriptor.typeParameters; widgetInfoSubject.next(widgetInfo);
widgetInfo.actionSources = widgetControllerDescriptor.actionSources; widgetInfoSubject.complete();
widgetInfo.widgetTypeFunction = widgetControllerDescriptor.widgetTypeFunction; }
this.widgetService.putWidgetInfoToCache(widgetInfo); this.resolveWidgetsInfoFetchQueue(widgetInfo.fullFqn, widgetInfo);
if (widgetInfoSubject) { },
widgetInfoSubject.next(widgetInfo); error: (errorMessages: string[]) => {
widgetInfoSubject.complete(); this.processWidgetLoadError(errorMessages, widgetInfo.fullFqn, widgetInfoSubject);
} }
this.resolveWidgetsInfoFetchQueue(widgetInfo.fullFqn, widgetInfo); }
);
}, },
(errorMessages: string[]) => { error: e => {
this.processWidgetLoadError(errorMessages, widgetInfo.fullFqn, widgetInfoSubject); const details = this.utils.parseException(e);
const errorMessage = `Failed to compile widget script. \n Error: ${details.message}`;
this.processWidgetLoadError([errorMessage], widgetInfo.fullFqn, widgetInfoSubject);
} }
); }
} );
} }
private loadWidgetResources(widgetInfo: WidgetInfo, widgetNamespace: string, modules?: Type<any>[]): Observable<any> { private loadWidgetResources(widgetInfo: WidgetInfo, widgetNamespace: string, modules?: Type<any>[]): Observable<any> {
@ -445,7 +451,15 @@ export class WidgetComponentService {
} }
} }
private createWidgetControllerDescriptor(widgetInfo: WidgetInfo): WidgetControllerDescriptor { private createWidgetControllerDescriptor(widgetInfo: WidgetInfo): Observable<WidgetControllerDescriptor> {
let controllerBody: string;
let modules: {[alias: string]: string} = null;
if (typeof widgetInfo.controllerScript === 'string') {
controllerBody = widgetInfo.controllerScript;
} else {
controllerBody = widgetInfo.controllerScript.body;
modules = widgetInfo.controllerScript.modules;
}
let widgetTypeFunctionBody = `return function _${widgetInfo.fullFqn.replace(/\./g, '_')} (ctx) {\n` + let widgetTypeFunctionBody = `return function _${widgetInfo.fullFqn.replace(/\./g, '_')} (ctx) {\n` +
' var self = this;\n' + ' var self = this;\n' +
' self.ctx = ctx;\n\n'; /*+ ' self.ctx = ctx;\n\n'; /*+
@ -505,113 +519,124 @@ export class WidgetComponentService {
' }\n\n' + ' }\n\n' +
'}';*/ '}';*/
widgetTypeFunctionBody += widgetInfo.controllerScript; widgetTypeFunctionBody += controllerBody;
widgetTypeFunctionBody += '\n};\n'; widgetTypeFunctionBody += '\n};\n';
try { let tbWidgetTypeFunction: TbFunction;
if (modules && Object.keys(modules).length) {
const widgetTypeFunction = new Function(widgetTypeFunctionBody); tbWidgetTypeFunction = {
const widgetType = widgetTypeFunction.apply(this); body: widgetTypeFunctionBody,
const widgetTypeInstance: WidgetTypeInstance = new widgetType(); modules
const result: WidgetControllerDescriptor = {
widgetTypeFunction: widgetType
};
if (isFunction(widgetTypeInstance.getSettingsSchema)) {
result.settingsSchema = widgetTypeInstance.getSettingsSchema();
}
if (isFunction(widgetTypeInstance.getDataKeySettingsSchema)) {
result.dataKeySettingsSchema = widgetTypeInstance.getDataKeySettingsSchema();
}
if (isFunction(widgetTypeInstance.getLatestDataKeySettingsSchema)) {
result.latestDataKeySettingsSchema = widgetTypeInstance.getLatestDataKeySettingsSchema();
}
if (isFunction(widgetTypeInstance.typeParameters)) {
result.typeParameters = widgetTypeInstance.typeParameters();
} else {
result.typeParameters = {};
}
if (isFunction(widgetTypeInstance.useCustomDatasources)) {
result.typeParameters.useCustomDatasources = widgetTypeInstance.useCustomDatasources();
} else {
result.typeParameters.useCustomDatasources = false;
}
if (isUndefined(result.typeParameters.hasDataPageLink)) {
result.typeParameters.hasDataPageLink = false;
}
if (isUndefined(result.typeParameters.maxDatasources)) {
result.typeParameters.maxDatasources = -1;
}
if (isUndefined(result.typeParameters.maxDataKeys)) {
result.typeParameters.maxDataKeys = -1;
}
if (isUndefined(result.typeParameters.singleEntity)) {
result.typeParameters.singleEntity = false;
}
if (isUndefined(result.typeParameters.hasAdditionalLatestDataKeys)) {
result.typeParameters.hasAdditionalLatestDataKeys = false;
}
if (isUndefined(result.typeParameters.warnOnPageDataOverflow)) {
result.typeParameters.warnOnPageDataOverflow = true;
}
if (isUndefined(result.typeParameters.ignoreDataUpdateOnIntervalTick)) {
result.typeParameters.ignoreDataUpdateOnIntervalTick = false;
}
if (isUndefined(result.typeParameters.dataKeysOptional)) {
result.typeParameters.dataKeysOptional = false;
}
if (isUndefined(result.typeParameters.datasourcesOptional)) {
result.typeParameters.datasourcesOptional = false;
}
if (isUndefined(result.typeParameters.stateData)) {
result.typeParameters.stateData = false;
}
if (isUndefined(result.typeParameters.processNoDataByWidget)) {
result.typeParameters.processNoDataByWidget = false;
}
if (isUndefined(result.typeParameters.previewWidth)) {
result.typeParameters.previewWidth = '100%';
}
if (isUndefined(result.typeParameters.previewHeight)) {
result.typeParameters.previewHeight = '70%';
}
if (isUndefined(result.typeParameters.embedTitlePanel)) {
result.typeParameters.embedTitlePanel = false;
}
if (isUndefined(result.typeParameters.overflowVisible)) {
result.typeParameters.overflowVisible = false;
}
if (isUndefined(result.typeParameters.hideDataSettings)) {
result.typeParameters.hideDataSettings = false;
}
if (!isFunction(result.typeParameters.defaultDataKeysFunction)) {
result.typeParameters.defaultDataKeysFunction = null;
}
if (!isFunction(result.typeParameters.defaultLatestDataKeysFunction)) {
result.typeParameters.defaultLatestDataKeysFunction = null;
} }
if (!isFunction(result.typeParameters.dataKeySettingsFunction)) { } else {
result.typeParameters.dataKeySettingsFunction = null; tbWidgetTypeFunction = widgetTypeFunctionBody;
}
if (isUndefined(result.typeParameters.displayRpcMessageToast)) {
result.typeParameters.displayRpcMessageToast = true;
}
if (isUndefined(result.typeParameters.targetDeviceOptional)) {
result.typeParameters.targetDeviceOptional = false;
}
if (isFunction(widgetTypeInstance.actionSources)) {
result.actionSources = widgetTypeInstance.actionSources();
} else {
result.actionSources = {};
}
for (const actionSourceId of Object.keys(widgetActionSources)) {
result.actionSources[actionSourceId] = {...widgetActionSources[actionSourceId]};
result.actionSources[actionSourceId].name = this.translate.instant(result.actionSources[actionSourceId].name);
}
return result;
} catch (e) {
this.utils.processWidgetException(e);
throw e;
} }
return compileTbFunction(this.http, tbWidgetTypeFunction).pipe(
map((compiled) => {
const widgetType = compiled.apply(this);
const widgetTypeInstance: WidgetTypeInstance = new widgetType();
const result: WidgetControllerDescriptor = {
widgetTypeFunction: widgetType
};
if (isFunction(widgetTypeInstance.getSettingsSchema)) {
result.settingsSchema = widgetTypeInstance.getSettingsSchema();
}
if (isFunction(widgetTypeInstance.getDataKeySettingsSchema)) {
result.dataKeySettingsSchema = widgetTypeInstance.getDataKeySettingsSchema();
}
if (isFunction(widgetTypeInstance.getLatestDataKeySettingsSchema)) {
result.latestDataKeySettingsSchema = widgetTypeInstance.getLatestDataKeySettingsSchema();
}
if (isFunction(widgetTypeInstance.typeParameters)) {
result.typeParameters = widgetTypeInstance.typeParameters();
} else {
result.typeParameters = {};
}
if (isFunction(widgetTypeInstance.useCustomDatasources)) {
result.typeParameters.useCustomDatasources = widgetTypeInstance.useCustomDatasources();
} else {
result.typeParameters.useCustomDatasources = false;
}
if (isUndefined(result.typeParameters.hasDataPageLink)) {
result.typeParameters.hasDataPageLink = false;
}
if (isUndefined(result.typeParameters.maxDatasources)) {
result.typeParameters.maxDatasources = -1;
}
if (isUndefined(result.typeParameters.maxDataKeys)) {
result.typeParameters.maxDataKeys = -1;
}
if (isUndefined(result.typeParameters.singleEntity)) {
result.typeParameters.singleEntity = false;
}
if (isUndefined(result.typeParameters.hasAdditionalLatestDataKeys)) {
result.typeParameters.hasAdditionalLatestDataKeys = false;
}
if (isUndefined(result.typeParameters.warnOnPageDataOverflow)) {
result.typeParameters.warnOnPageDataOverflow = true;
}
if (isUndefined(result.typeParameters.ignoreDataUpdateOnIntervalTick)) {
result.typeParameters.ignoreDataUpdateOnIntervalTick = false;
}
if (isUndefined(result.typeParameters.dataKeysOptional)) {
result.typeParameters.dataKeysOptional = false;
}
if (isUndefined(result.typeParameters.datasourcesOptional)) {
result.typeParameters.datasourcesOptional = false;
}
if (isUndefined(result.typeParameters.stateData)) {
result.typeParameters.stateData = false;
}
if (isUndefined(result.typeParameters.processNoDataByWidget)) {
result.typeParameters.processNoDataByWidget = false;
}
if (isUndefined(result.typeParameters.previewWidth)) {
result.typeParameters.previewWidth = '100%';
}
if (isUndefined(result.typeParameters.previewHeight)) {
result.typeParameters.previewHeight = '70%';
}
if (isUndefined(result.typeParameters.embedTitlePanel)) {
result.typeParameters.embedTitlePanel = false;
}
if (isUndefined(result.typeParameters.overflowVisible)) {
result.typeParameters.overflowVisible = false;
}
if (isUndefined(result.typeParameters.hideDataSettings)) {
result.typeParameters.hideDataSettings = false;
}
if (!isFunction(result.typeParameters.defaultDataKeysFunction)) {
result.typeParameters.defaultDataKeysFunction = null;
}
if (!isFunction(result.typeParameters.defaultLatestDataKeysFunction)) {
result.typeParameters.defaultLatestDataKeysFunction = null;
}
if (!isFunction(result.typeParameters.dataKeySettingsFunction)) {
result.typeParameters.dataKeySettingsFunction = null;
}
if (isUndefined(result.typeParameters.displayRpcMessageToast)) {
result.typeParameters.displayRpcMessageToast = true;
}
if (isUndefined(result.typeParameters.targetDeviceOptional)) {
result.typeParameters.targetDeviceOptional = false;
}
if (isFunction(widgetTypeInstance.actionSources)) {
result.actionSources = widgetTypeInstance.actionSources();
} else {
result.actionSources = {};
}
for (const actionSourceId of Object.keys(widgetActionSources)) {
result.actionSources[actionSourceId] = {...widgetActionSources[actionSourceId]};
result.actionSources[actionSourceId].name = this.translate.instant(result.actionSources[actionSourceId].name);
}
return result;
}),
catchError((e) => {
this.utils.processWidgetException(e);
return throwError(() => e);
})
);
} }
private processWidgetLoadError(errorMessages: string[], fullFqn: string, widgetInfoSubject: Subject<WidgetInfo>) { private processWidgetLoadError(errorMessages: string[], fullFqn: string, widgetInfoSubject: Subject<WidgetInfo>) {

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

@ -120,6 +120,8 @@ import { DASHBOARD_PAGE_COMPONENT_TOKEN } from '@home/components/tokens';
import { MODULES_MAP } from '@shared/models/constants'; import { MODULES_MAP } from '@shared/models/constants';
import { IModulesMap } from '@modules/common/modules-map.models'; import { IModulesMap } from '@modules/common/modules-map.models';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
import { CompiledTbFunction, compileTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models';
import { HttpClient } from '@angular/common/http';
@Component({ @Component({
selector: 'tb-widget', selector: 'tb-widget',
@ -208,7 +210,8 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
private mobileService: MobileService, private mobileService: MobileService,
private raf: RafService, private raf: RafService,
private ngZone: NgZone, private ngZone: NgZone,
private cd: ChangeDetectorRef) { private cd: ChangeDetectorRef,
private http: HttpClient) {
super(store); super(store);
this.cssParser.testMode = false; this.cssParser.testMode = false;
} }
@ -269,37 +272,49 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
}; };
this.widgetContext.customHeaderActions = []; this.widgetContext.customHeaderActions = [];
const headerActionsDescriptors = this.getActionDescriptors(widgetActionSources.headerButton.value); const headerActionsDescriptors = this.getActionDescriptors(widgetActionSources.headerButton.value);
headerActionsDescriptors.forEach((descriptor) =>
{ const customHeaderActions$ = headerActionsDescriptors.map((descriptor) => {
let useShowWidgetHeaderActionFunction = descriptor.useShowWidgetActionFunction || false; let useShowWidgetHeaderActionFunction = descriptor.useShowWidgetActionFunction || false;
let showWidgetHeaderActionFunction: ShowWidgetHeaderActionFunction = null; let showWidgetHeaderActionFunction$: Observable<CompiledTbFunction<ShowWidgetHeaderActionFunction>>;
if (useShowWidgetHeaderActionFunction && isNotEmptyStr(descriptor.showWidgetActionFunction)) { if (useShowWidgetHeaderActionFunction && isNotEmptyTbFunction(descriptor.showWidgetActionFunction)) {
try { showWidgetHeaderActionFunction$ = compileTbFunction(this.http, descriptor.showWidgetActionFunction, 'widgetContext', 'data');
showWidgetHeaderActionFunction = } else {
new Function('widgetContext', 'data', descriptor.showWidgetActionFunction) as ShowWidgetHeaderActionFunction; showWidgetHeaderActionFunction$ = of(null);
} catch (e) {
useShowWidgetHeaderActionFunction = false;
}
} }
const headerAction: WidgetHeaderAction = { return showWidgetHeaderActionFunction$.pipe(
name: descriptor.name, catchError(() => { return of(null) }),
displayName: descriptor.displayName, map(showWidgetHeaderActionFunction => {
icon: descriptor.icon, if (!showWidgetHeaderActionFunction) {
descriptor, useShowWidgetHeaderActionFunction = false;
useShowWidgetHeaderActionFunction, }
showWidgetHeaderActionFunction, const headerAction: WidgetHeaderAction = {
onAction: $event => { name: descriptor.name,
const entityInfo = this.getActiveEntityInfo(); displayName: descriptor.displayName,
const entityId = entityInfo ? entityInfo.entityId : null; icon: descriptor.icon,
const entityName = entityInfo ? entityInfo.entityName : null; descriptor,
const entityLabel = entityInfo ? entityInfo.entityLabel : null; useShowWidgetHeaderActionFunction,
this.handleWidgetAction($event, descriptor, entityId, entityName, null, entityLabel); showWidgetHeaderActionFunction,
} onAction: $event => {
}; const entityInfo = this.getActiveEntityInfo();
this.widgetContext.customHeaderActions.push(headerAction); const entityId = entityInfo ? entityInfo.entityId : null;
const entityName = entityInfo ? entityInfo.entityName : null;
const entityLabel = entityInfo ? entityInfo.entityLabel : null;
this.handleWidgetAction($event, descriptor, entityId, entityName, null, entityLabel);
}
};
return headerAction;
})
);
}); });
if (customHeaderActions$.length) {
forkJoin(customHeaderActions$).subscribe((customHeaderActions) => {
this.widgetContext.customHeaderActions.push(...customHeaderActions);
});
}
this.subscriptionContext = new WidgetSubscriptionContext(this.widgetContext.dashboard); this.subscriptionContext = new WidgetSubscriptionContext(this.widgetContext.dashboard);
this.subscriptionContext.timeService = this.timeService; this.subscriptionContext.timeService = this.timeService;
this.subscriptionContext.deviceService = this.deviceService; this.subscriptionContext.deviceService = this.deviceService;
@ -1108,17 +1123,25 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
break; break;
case WidgetActionType.custom: case WidgetActionType.custom:
const customFunction = descriptor.customFunction; const customFunction = descriptor.customFunction;
if (customFunction && customFunction.length > 0) { if (isNotEmptyTbFunction(customFunction)) {
try { compileTbFunction(this.http, customFunction, '$event', 'widgetContext', 'entityId',
if (!additionalParams) { 'entityName', 'additionalParams', 'entityLabel').subscribe(
additionalParams = {}; {
next: (compiled) => {
try {
if (!additionalParams) {
additionalParams = {};
}
compiled.execute($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
} catch (e) {
console.error(e);
}
},
error: (err) => {
console.error(err);
}
} }
const customActionFunction = new Function('$event', 'widgetContext', 'entityId', )
'entityName', 'additionalParams', 'entityLabel', customFunction);
customActionFunction($event, this.widgetContext, entityId, entityName, additionalParams, entityLabel);
} catch (e) {
console.error(e);
}
} }
break; break;
case WidgetActionType.customPretty: case WidgetActionType.customPretty:
@ -1133,18 +1156,26 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
} }
this.loadCustomActionResources(actionNamespace, customCss, customResources, descriptor).subscribe({ this.loadCustomActionResources(actionNamespace, customCss, customResources, descriptor).subscribe({
next: () => { next: () => {
if (isDefined(customPrettyFunction) && customPrettyFunction.length > 0) { if (isNotEmptyTbFunction(customPrettyFunction)) {
try { compileTbFunction(this.http, customPrettyFunction, '$event', 'widgetContext', 'entityId',
if (!additionalParams) { 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel').subscribe(
additionalParams = {}; {
next: (compiled) => {
try {
if (!additionalParams) {
additionalParams = {};
}
this.widgetContext.customDialog.setAdditionalImports(descriptor.customImports);
compiled.execute($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel);
} catch (e) {
console.error(e);
}
},
error: (err) => {
console.error(err);
}
} }
const customActionPrettyFunction = new Function('$event', 'widgetContext', 'entityId', )
'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel', customPrettyFunction);
this.widgetContext.customDialog.setAdditionalImports(descriptor.customImports);
customActionPrettyFunction($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel);
} catch (e) {
console.error(e);
}
} }
}, },
error: (errorMessages: string[]) => { error: (errorMessages: string[]) => {

2
ui-ngx/src/app/modules/home/models/dashboard-component.models.ts

@ -686,7 +686,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
private filterCustomHeaderAction(action: WidgetHeaderAction, data: FormattedData[]): boolean { private filterCustomHeaderAction(action: WidgetHeaderAction, data: FormattedData[]): boolean {
if (action.useShowWidgetHeaderActionFunction) { if (action.useShowWidgetHeaderActionFunction) {
try { try {
return action.showWidgetHeaderActionFunction(this.widgetContext, data); return action.showWidgetHeaderActionFunction.execute(this.widgetContext, data);
} catch (e) { } catch (e) {
console.warn('Failed to execute showWidgetHeaderActionFunction', e); console.warn('Failed to execute showWidgetHeaderActionFunction', e);
return false; return false;

3
ui-ngx/src/app/modules/home/models/widget-component.models.ts

@ -105,6 +105,7 @@ import { UserId } from '@shared/models/id/user-id';
import { UserSettingsService } from '@core/http/user-settings.service'; import { UserSettingsService } from '@core/http/user-settings.service';
import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { CompiledTbFunction } from '@shared/models/js-function.models';
export interface IWidgetAction { export interface IWidgetAction {
name: string; name: string;
@ -118,7 +119,7 @@ export interface WidgetHeaderAction extends IWidgetAction {
displayName: string; displayName: string;
descriptor: WidgetActionDescriptor; descriptor: WidgetActionDescriptor;
useShowWidgetHeaderActionFunction: boolean; useShowWidgetHeaderActionFunction: boolean;
showWidgetHeaderActionFunction: ShowWidgetHeaderActionFunction; showWidgetHeaderActionFunction: CompiledTbFunction<ShowWidgetHeaderActionFunction>;
} }
export interface WidgetAction extends IWidgetAction { export interface WidgetAction extends IWidgetAction {

39
ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts

@ -45,6 +45,7 @@ import { ImageService } from '@core/http/image.service';
import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol-editor.models'; import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol-editor.models';
import { MenuId } from '@core/services/menu.models'; import { MenuId } from '@core/services/menu.models';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { JsLibraryTableConfigResolver } from '@home/pages/admin/resource/js-library-table-config.resolver';
export const scadaSymbolResolver: ResolveFn<ScadaSymbolData> = export const scadaSymbolResolver: ResolveFn<ScadaSymbolData> =
(route: ActivatedRouteSnapshot, (route: ActivatedRouteSnapshot,
@ -177,6 +178,43 @@ const routes: Routes = [
} }
} }
] ]
},
{
path: 'javascript-library',
data: {
breadcrumb: {
menuId: MenuId.javascript_library
}
},
children: [
{
path: '',
component: EntitiesTableComponent,
data: {
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'javascript.javascript-library',
},
resolve: {
entitiesTableConfig: JsLibraryTableConfigResolver
}
},
{
path: ':entityId',
component: EntityDetailsPageComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
breadcrumb: {
labelFunction: entityDetailsPageBreadcrumbLabelFunction,
icon: 'mdi:language-javascript'
} as BreadCrumbConfig<EntityDetailsPageComponent>,
auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN],
title: 'javascript.javascript-library'
},
resolve: {
entitiesTableConfig: JsLibraryTableConfigResolver
}
}
]
} }
] ]
}, },
@ -393,6 +431,7 @@ const routes: Routes = [
exports: [RouterModule], exports: [RouterModule],
providers: [ providers: [
ResourcesLibraryTableConfigResolver, ResourcesLibraryTableConfigResolver,
JsLibraryTableConfigResolver,
QueuesTableConfigResolver QueuesTableConfigResolver
] ]
}) })

20
ui-ngx/src/app/modules/home/pages/admin/admin.module.ts

@ -33,6 +33,9 @@ import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-a
import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component'; import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component';
import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component'; import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component';
import { OAuth2Module } from '@home/pages/admin/oauth2/oauth2.module'; import { OAuth2Module } from '@home/pages/admin/oauth2/oauth2.module';
import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-library-table-header.component';
import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component';
import { NgxFlowModule } from '@flowjs/ngx-flow';
@NgModule({ @NgModule({
declarations: declarations:
@ -45,17 +48,20 @@ import { OAuth2Module } from '@home/pages/admin/oauth2/oauth2.module';
HomeSettingsComponent, HomeSettingsComponent,
ResourcesLibraryComponent, ResourcesLibraryComponent,
ResourcesTableHeaderComponent, ResourcesTableHeaderComponent,
JsResourceComponent,
JsLibraryTableHeaderComponent,
QueueComponent, QueueComponent,
RepositoryAdminSettingsComponent, RepositoryAdminSettingsComponent,
AutoCommitAdminSettingsComponent, AutoCommitAdminSettingsComponent,
TwoFactorAuthSettingsComponent TwoFactorAuthSettingsComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
SharedModule, SharedModule,
HomeComponentsModule, HomeComponentsModule,
AdminRoutingModule, AdminRoutingModule,
OAuth2Module OAuth2Module,
] NgxFlowModule
]
}) })
export class AdminModule { } export class AdminModule { }

171
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts

@ -0,0 +1,171 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Injectable } from '@angular/core';
import {
checkBoxCell,
DateEntityTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { Router } from '@angular/router';
import {
Resource,
ResourceInfo,
ResourceSubType,
ResourceSubTypeTranslationMap,
ResourceType
} from '@shared/models/resource.models';
import { EntityType, entityTypeResources } from '@shared/models/entity-type.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DatePipe } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { ResourceService } from '@core/http/resource.service';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Authority } from '@shared/models/authority.enum';
import { PageLink } from '@shared/models/page/page-link';
import { EntityAction } from '@home/models/entity/entity-component.models';
import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-library-table-header.component';
import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component';
import { switchMap } from 'rxjs/operators';
@Injectable()
export class JsLibraryTableConfigResolver {
private readonly config: EntityTableConfig<Resource, PageLink, ResourceInfo> = new EntityTableConfig<Resource, PageLink, ResourceInfo>();
constructor(private store: Store<AppState>,
private resourceService: ResourceService,
private translate: TranslateService,
private router: Router,
private datePipe: DatePipe) {
this.config.entityType = EntityType.TB_RESOURCE;
this.config.entityComponent = JsResourceComponent;
this.config.entityTranslations = {
details: 'javascript.javascript-resource-details',
add: 'javascript.add',
noEntities: 'javascript.no-javascript-resource-text',
search: 'javascript.search',
selectedEntities: 'javascript.selected-javascript-resources'
};
this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE);
this.config.headerComponent = JsLibraryTableHeaderComponent;
this.config.entityTitle = (resource) => resource ?
resource.title : '';
this.config.columns.push(
new DateEntityTableColumn<ResourceInfo>('createdTime', 'common.created-time', this.datePipe, '150px'),
new EntityTableColumn<ResourceInfo>('title', 'resource.title', '60%'),
new EntityTableColumn<ResourceInfo>('resourceSubType', 'javascript.javascript-type', '40%',
entity => this.translate.instant(ResourceSubTypeTranslationMap.get(entity.resourceSubType))),
new EntityTableColumn<ResourceInfo>('tenantId', 'resource.system', '60px',
entity => checkBoxCell(entity.tenantId.id === NULL_UUID)),
);
this.config.cellActionDescriptors.push(
{
name: this.translate.instant('javascript.download'),
icon: 'file_download',
isEnabled: () => true,
onAction: ($event, entity) => this.downloadResource($event, entity)
}
);
this.config.deleteEntityTitle = resource => this.translate.instant('javascript.delete-javascript-resource-title',
{ resourceTitle: resource.title });
this.config.deleteEntityContent = () => this.translate.instant('javascript.delete-javascript-resource-text');
this.config.deleteEntitiesTitle = count => this.translate.instant('javascript.delete-javascript-resources-title', {count});
this.config.deleteEntitiesContent = () => this.translate.instant('javascript.delete-javascript-resources-text');
this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, ResourceType.JS_MODULE, this.config.componentsData.resourceSubType);
this.config.loadEntity = id => {
const current = this.config.getTable()?.dataSource?.currentEntity as ResourceInfo;
if (!current || current?.resourceSubType === ResourceSubType.MODULE) {
return this.resourceService.getResource(id.id);
} else {
return this.resourceService.getResourceInfoById(id.id)
}
};
this.config.saveEntity = resource => {
resource.resourceType = ResourceType.JS_MODULE;
let saveObservable = this.resourceService.saveResource(resource);
if (resource.resourceSubType === ResourceSubType.MODULE) {
saveObservable = saveObservable.pipe(
switchMap((saved) => this.resourceService.getResource(saved.id.id))
);
}
return saveObservable;
};
this.config.deleteEntity = id => this.resourceService.deleteResource(id.id);
this.config.onEntityAction = action => this.onResourceAction(action);
}
resolve(): EntityTableConfig<Resource, PageLink, ResourceInfo> {
this.config.tableTitle = this.translate.instant('javascript.javascript-library');
this.config.componentsData = {
resourceSubType: ''
};
const authUser = getCurrentAuthUser(this.store);
this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority);
this.config.entitySelectionEnabled = (resource) => this.isResourceEditable(resource, authUser.authority);
this.config.detailsReadonly = (resource) => this.detailsReadonly(resource, authUser.authority);
return this.config;
}
private openResource($event: Event, resourceInfo: ResourceInfo) {
if ($event) {
$event.stopPropagation();
}
const url = this.router.createUrlTree(['resources', 'javascript-library', resourceInfo.id.id]);
this.router.navigateByUrl(url).then(() => {});
}
downloadResource($event: Event, resource: ResourceInfo) {
if ($event) {
$event.stopPropagation();
}
this.resourceService.downloadResource(resource.id.id).subscribe();
}
onResourceAction(action: EntityAction<ResourceInfo>): boolean {
switch (action.action) {
case 'open':
this.openResource(action.event, action.entity);
return true;
case 'downloadResource':
this.downloadResource(action.event, action.entity);
return true;
}
return false;
}
private detailsReadonly(resource: ResourceInfo, authority: Authority): boolean {
return !this.isResourceEditable(resource, authority);
}
private isResourceEditable(resource: ResourceInfo, authority: Authority): boolean {
if (authority === Authority.TENANT_ADMIN) {
return resource && resource.tenantId && resource.tenantId.id !== NULL_UUID;
} else {
return authority === Authority.SYS_ADMIN;
}
}
}

30
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html

@ -0,0 +1,30 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>javascript.javascript-type</mat-label>
<mat-select [ngModel]="entitiesTableConfig.componentsData.resourceSubType"
(ngModelChange)="jsResourceSubTypeChanged($event)"
placeholder="{{ 'javascript.javascript-type' | translate }}">
<mat-option value="">
{{ "javascript.all-types" | translate }}
</mat-option>
<mat-option *ngFor="let jsResourceSubType of jsResourceSubTypes" [value]="jsResourceSubType">
{{ resourceSubTypesTranslationMap.get(jsResourceSubType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>

42
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.ts

@ -0,0 +1,42 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component';
import { Resource, ResourceInfo, ResourceSubType, ResourceSubTypeTranslationMap } from '@shared/models/resource.models';
import { PageLink } from '@shared/models/page/page-link';
@Component({
selector: 'tb-js-library-table-header',
templateUrl: './js-library-table-header.component.html',
styleUrls: []
})
export class JsLibraryTableHeaderComponent extends EntityTableHeaderComponent<Resource, PageLink, ResourceInfo> {
readonly jsResourceSubTypes: ResourceSubType[] = [ResourceSubType.EXTENSION, ResourceSubType.MODULE];
readonly resourceSubTypesTranslationMap = ResourceSubTypeTranslationMap;
constructor(protected store: Store<AppState>) {
super(store);
}
jsResourceSubTypeChanged(resourceSubType: ResourceSubType) {
this.entitiesTableConfig.componentsData.resourceSubType = resourceSubType;
this.entitiesTableConfig.getTable().resetSortAndFilter(true);
}
}

117
ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html

@ -0,0 +1,117 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
[class.!hidden]="isEdit || isDetailsPage">
{{'common.open-details-page' | translate }}
</button>
<button mat-raised-button color="primary" class="xs:flex-1"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'downloadResource')"
[class.!hidden]="isEdit">
{{ 'javascript.download' | translate }}
</button>
<button mat-raised-button color="primary" class="xs:flex-1"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"
[class.!hidden]="hideDelete() || isEdit">
{{ 'javascript.delete' | translate }}
</button>
<div class="flex flex-row xs:flex-col">
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onResourceIdCopied()"
[cbContent]="entity?.id?.id"
[class.!hidden]="isEdit">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>resource.copyId</span>
</button>
</div>
</div>
<div class="mat-padding flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<mat-label translate>javascript.javascript-type</mat-label>
<mat-select formControlName="resourceSubType" required>
<mat-option *ngFor="let resourceSubType of jsResourceSubTypes" [value]="resourceSubType">
{{ ResourceSubTypeTranslationMap.get(resourceSubType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>resource.title</mat-label>
<input matInput formControlName="title" required>
<mat-error *ngIf="entityForm.get('title').hasError('required')">
{{ 'resource.title-required' | translate }}
</mat-error>
<mat-error *ngIf="entityForm.get('title').hasError('maxlength')">
{{ 'resource.title-max-length' | translate }}
</mat-error>
</mat-form-field>
<tb-file-input *ngIf="(isAdd || isEdit) && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION"
formControlName="data"
[required]="isAdd"
label="{{ 'javascript.resource-file' | translate }}"
[readAsBinary]="true"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File"
[accept]="getAcceptType()"
dropLabel="{{'javascript.drop-resource-file-or' | translate}}"
[existingFileName]="entityForm.get('fileName')?.value"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
<tb-js-func *ngIf="entityForm.get('resourceSubType').value === ResourceSubType.MODULE"
formControlName="content"
required
hideBrackets
hideLabel
minHeight="300px">
<div toolbarStartButton
class="flex flex-row gap-4">
<label class="tb-title no-padding tb-required"
[class.tb-error]="entityForm.get('content').invalid && entityForm.get('content').touched"
style="font-size: 16px;">
{{ 'javascript.module-script' | translate }}
</label>
<tb-file-input *ngIf="(isAdd || isEdit)"
asButton
uploadButtonText="{{ 'javascript.upload-from-file' | translate }}"
uploadButtonClass="tb-ignore-browse-file-button-style"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[accept]="getAcceptType()"
[ngModel]=""
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="uploadContentFromFile($event)"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
</div>
</tb-js-func>
<div *ngIf="!isAdd && !isEdit && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION" class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="flex-1">
<mat-label translate>resource.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>
</div>
</fieldset>
</form>
</div>

176
ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts

@ -0,0 +1,176 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { EntityComponent } from '@home/components/entity/entity.component';
import {
Resource,
ResourceSubType,
ResourceSubTypeTranslationMap,
ResourceType,
ResourceTypeExtension,
ResourceTypeMIMETypes
} from '@shared/models/resource.models';
import { startWith, takeUntil } from 'rxjs/operators';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { isDefinedAndNotNull } from '@core/utils';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { scadaSymbolGeneralStateHighlightRules } from '@home/pages/scada-symbol/scada-symbol-editor.models';
@Component({
selector: 'tb-js-resource',
templateUrl: './js-resource.component.html'
})
export class JsResourceComponent extends EntityComponent<Resource> implements OnInit, OnDestroy {
readonly ResourceSubType = ResourceSubType;
readonly jsResourceSubTypes: ResourceSubType[] = [ResourceSubType.EXTENSION, ResourceSubType.MODULE];
readonly ResourceSubTypeTranslationMap = ResourceSubTypeTranslationMap;
readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize;
private destroy$ = new Subject<void>();
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: Resource,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<Resource>,
public fb: FormBuilder,
protected cd: ChangeDetectorRef) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
}
ngOnInit(): void {
super.ngOnInit();
if (this.isAdd) {
this.observeResourceSubTypeChange();
}
}
ngOnDestroy(): void {
super.ngOnDestroy();
this.destroy$.next();
this.destroy$.complete();
}
hideDelete(): boolean {
if (this.entitiesTableConfig) {
return !this.entitiesTableConfig.deleteEnabled(this.entity);
} else {
return false;
}
}
buildForm(entity: Resource): FormGroup {
return this.fb.group({
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]],
resourceSubType: [entity?.resourceSubType ? entity.resourceSubType : ResourceSubType.EXTENSION, Validators.required],
fileName: [entity ? entity.fileName : null, Validators.required],
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []],
content: [entity?.data?.length ? window.atob(entity.data) : '', Validators.required]
});
}
updateForm(entity: Resource): void {
this.entityForm.patchValue(entity);
const content = entity.resourceSubType === ResourceSubType.MODULE && entity?.data?.length ? window.atob(entity.data) : '';
this.entityForm.get('content').patchValue(content);
}
override updateFormState(): void {
super.updateFormState();
if (this.isEdit && this.entityForm && !this.isAdd) {
this.entityForm.get('resourceSubType').disable({ emitEvent: false });
this.updateResourceSubTypeFieldsState(this.entityForm.get('resourceSubType').value);
}
}
prepareFormValue(formValue: Resource): Resource {
if (this.isEdit && !isDefinedAndNotNull(formValue.data)) {
delete formValue.data;
}
if (formValue.resourceSubType === ResourceSubType.MODULE) {
if (!formValue.fileName) {
formValue.fileName = formValue.title + '.js';
}
formValue.data = window.btoa((formValue as any).content);
delete (formValue as any).content;
}
return super.prepareFormValue(formValue);
}
getAllowedExtensions(): string {
return ResourceTypeExtension.get(ResourceType.JS_MODULE);
}
getAcceptType(): string {
return ResourceTypeMIMETypes.get(ResourceType.JS_MODULE);
}
convertToBase64File(data: string): string {
return window.btoa(data);
}
onResourceIdCopied(): void {
this.store.dispatch(new ActionNotificationShow(
{
message: this.translate.instant('resource.idCopiedMessage'),
type: 'success',
duration: 750,
verticalPosition: 'bottom',
horizontalPosition: 'right'
}));
}
uploadContentFromFile(content: string) {
this.entityForm.get('content').patchValue(content);
this.entityForm.markAsDirty();
}
private observeResourceSubTypeChange(): void {
this.entityForm.get('resourceSubType').valueChanges.pipe(
startWith(ResourceSubType.EXTENSION),
takeUntil(this.destroy$)
).subscribe((subType: ResourceSubType) => this.onResourceSubTypeChange(subType));
}
private onResourceSubTypeChange(subType: ResourceSubType): void {
this.updateResourceSubTypeFieldsState(subType);
this.entityForm.patchValue({
data: null,
fileName: null
}, {emitEvent: false});
}
private updateResourceSubTypeFieldsState(subType: ResourceSubType) {
if (subType === ResourceSubType.EXTENSION) {
this.entityForm.get('data').enable({ emitEvent: false });
this.entityForm.get('fileName').enable({ emitEvent: false });
this.entityForm.get('content').disable({ emitEvent: false });
} else {
this.entityForm.get('data').disable({ emitEvent: false });
this.entityForm.get('fileName').disable({ emitEvent: false });
this.entityForm.get('content').enable({ emitEvent: false });
}
}
protected readonly highlightRules = scadaSymbolGeneralStateHighlightRules;
}

6
ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html

@ -66,9 +66,9 @@
{{ 'resource.title-max-length' | translate }} {{ 'resource.title-max-length' | translate }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<tb-file-input *ngIf="isAdd || (isEdit && entityForm.get('resourceType').value === resourceType.JS_MODULE)" <tb-file-input *ngIf="isAdd"
formControlName="data" formControlName="data"
[required]="isAdd" required
label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}" label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}"
[readAsBinary]="true" [readAsBinary]="true"
[maxSizeByte]="maxResourceSize" [maxSizeByte]="maxResourceSize"
@ -80,7 +80,7 @@
[existingFileName]="entityForm.get('fileName')?.value" [existingFileName]="entityForm.get('fileName')?.value"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)"> (fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input> </tb-file-input>
<div *ngIf="!isAdd && !(isEdit && entityForm.get('resourceType').value === resourceType.JS_MODULE)" class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2"> <div *ngIf="!isAdd" class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="flex-1"> <mat-form-field class="flex-1">
<mat-label translate>resource.file-name</mat-label> <mat-label translate>resource.file-name</mat-label>
<input matInput formControlName="fileName" type="text"> <input matInput formControlName="fileName" type="text">

10
ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts

@ -41,7 +41,7 @@ import { getCurrentAuthState } from '@core/auth/auth.selectors';
export class ResourcesLibraryComponent extends EntityComponent<Resource> implements OnInit, OnDestroy { export class ResourcesLibraryComponent extends EntityComponent<Resource> implements OnInit, OnDestroy {
readonly resourceType = ResourceType; readonly resourceType = ResourceType;
readonly resourceTypes: ResourceType[] = Object.values(this.resourceType); readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize; readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize;
@ -80,7 +80,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
buildForm(entity: Resource): FormGroup { buildForm(entity: Resource): FormGroup {
return this.fb.group({ return this.fb.group({
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]],
resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.JS_MODULE, Validators.required], resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required],
fileName: [entity ? entity.fileName : null, Validators.required], fileName: [entity ? entity.fileName : null, Validators.required],
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []] data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []]
}); });
@ -94,9 +94,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
super.updateFormState(); super.updateFormState();
if (this.isEdit && this.entityForm && !this.isAdd) { if (this.isEdit && this.entityForm && !this.isAdd) {
this.entityForm.get('resourceType').disable({ emitEvent: false }); this.entityForm.get('resourceType').disable({ emitEvent: false });
if (this.entityForm.get('resourceType').value !== ResourceType.JS_MODULE) { this.entityForm.get('fileName').disable({ emitEvent: false });
this.entityForm.get('fileName').disable({ emitEvent: false });
}
} }
} }
@ -140,7 +138,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
private observeResourceTypeChange(): void { private observeResourceTypeChange(): void {
this.entityForm.get('resourceType').valueChanges.pipe( this.entityForm.get('resourceType').valueChanges.pipe(
startWith(ResourceType.JS_MODULE), startWith(ResourceType.LWM2M_MODEL),
takeUntil(this.destroy$) takeUntil(this.destroy$)
).subscribe((type: ResourceType) => this.onResourceTypeChange(type)); ).subscribe((type: ResourceType) => this.onResourceTypeChange(type));
} }

2
ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts

@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link';
}) })
export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent<Resource, PageLink, ResourceInfo> { export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent<Resource, PageLink, ResourceInfo> {
readonly resourceTypes: ResourceType[] = Object.values(ResourceType); readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
constructor(protected store: Store<AppState>) { constructor(protected store: Store<AppState>) {

33
ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts

@ -15,7 +15,6 @@
/// ///
import { import {
AfterViewInit,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
EventEmitter, EventEmitter,
@ -68,13 +67,15 @@ import { Authority } from '@shared/models/authority.enum';
import { NULL_UUID } from '@shared/models/id/has-uuid'; import { NULL_UUID } from '@shared/models/id/has-uuid';
import { import {
UploadImageDialogComponent, UploadImageDialogComponent,
UploadImageDialogData, UploadImageDialogResult UploadImageDialogData,
UploadImageDialogResult
} from '@shared/components/image/upload-image-dialog.component'; } from '@shared/components/image/upload-image-dialog.component';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { BackgroundType, colorBackground } from '@shared/models/widget-settings.models'; import { colorBackground } from '@shared/models/widget-settings.models';
import { GridType } from 'angular-gridster2'; import { GridType } from 'angular-gridster2';
import { import {
SaveWidgetTypeAsDialogComponent, SaveWidgetTypeAsDialogData, SaveWidgetTypeAsDialogComponent,
SaveWidgetTypeAsDialogData,
SaveWidgetTypeAsDialogResult SaveWidgetTypeAsDialogResult
} from '@home/pages/widget/save-widget-type-as-dialog.component'; } from '@home/pages/widget/save-widget-type-as-dialog.component';
import { WidgetService } from '@core/http/widget.service'; import { WidgetService } from '@core/http/widget.service';
@ -86,7 +87,7 @@ import { WidgetService } from '@core/http/widget.service';
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class ScadaSymbolComponent extends PageComponent export class ScadaSymbolComponent extends PageComponent
implements OnInit, OnDestroy, AfterViewInit, HasDirtyFlag, ScadaSymbolEditObjectCallbacks { implements OnInit, OnDestroy, HasDirtyFlag, ScadaSymbolEditObjectCallbacks {
widgetType = widgetType; widgetType = widgetType;
@ -199,9 +200,6 @@ export class ScadaSymbolComponent extends PageComponent
); );
} }
ngAfterViewInit() {
}
ngOnDestroy() { ngOnDestroy() {
super.ngOnDestroy(); super.ngOnDestroy();
this.destroy$.next(); this.destroy$.next();
@ -420,10 +418,21 @@ export class ScadaSymbolComponent extends PageComponent
const descriptor = widget.descriptor; const descriptor = widget.descriptor;
descriptor.sizeX = metadata.widgetSizeX; descriptor.sizeX = metadata.widgetSizeX;
descriptor.sizeY = metadata.widgetSizeY; descriptor.sizeY = metadata.widgetSizeY;
descriptor.controllerScript = descriptor.controllerScript let controllerScriptBody: string;
.replace(/previewWidth: '\d*px'/gm, `previewWidth: '${metadata.widgetSizeX * 100}px'`); if (typeof descriptor.controllerScript === 'string') {
descriptor.controllerScript = descriptor.controllerScript controllerScriptBody = descriptor.controllerScript;
.replace(/previewHeight: '\d*px'/gm, `previewHeight: '${metadata.widgetSizeY * 100 + 20}px'`); } else {
controllerScriptBody = descriptor.controllerScript.body;
}
controllerScriptBody = controllerScriptBody
.replace(/previewWidth: '\d*px'/gm, `previewWidth: '${metadata.widgetSizeX * 100}px'`);
controllerScriptBody = controllerScriptBody
.replace(/previewHeight: '\d*px'/gm, `previewHeight: '${metadata.widgetSizeY * 100 + 20}px'`);
if (typeof descriptor.controllerScript === 'string') {
descriptor.controllerScript = controllerScriptBody;
} else {
descriptor.controllerScript.body = controllerScriptBody;
}
const config: WidgetConfig = JSON.parse(descriptor.defaultConfig); const config: WidgetConfig = JSON.parse(descriptor.defaultConfig);
config.title = saveWidgetAsData.widgetName; config.title = saveWidgetAsData.widgetName;
config.settings = config.settings || {}; config.settings = config.settings || {};

7
ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html

@ -308,6 +308,13 @@
<div class="tb-resize-container" tb-fullscreen [fullscreen]="javascriptFullscreen"> <div class="tb-resize-container" tb-fullscreen [fullscreen]="javascriptFullscreen">
<div class="tb-editor-area-title-panel"> <div class="tb-editor-area-title-panel">
<label translate>widget.javascript</label> <label translate>widget.javascript</label>
<button mat-icon-button class="tb-mat-32"
#editModulesButton
(click)="editControllerScriptModules($event, editModulesButton)"
matTooltip="{{'js-func.modules' | translate}}"
matTooltipPosition="above">
<tb-icon color="primary" matButtonIcon>mdi:application-braces-outline</tb-icon>
</button>
<button mat-button (click)="beautifyJs()"> <button mat-button (click)="beautifyJs()">
{{ 'widget.tidy' | translate }} {{ 'widget.tidy' | translate }}
</button> </button>

115
ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts

@ -21,8 +21,8 @@ import {
EventEmitter, EventEmitter,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit, OnInit, Renderer2,
ViewChild, ViewChild, ViewContainerRef,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@ -64,8 +64,13 @@ import { widgetEditorCompleter } from '@home/pages/widget/widget-editor.models';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { catchError, map, tap } from 'rxjs/operators'; import { catchError, map, tap } from 'rxjs/operators';
import { beautifyCss, beautifyHtml, beautifyJs } from '@shared/models/beautify.models'; import { beautifyCss, beautifyHtml, beautifyJs } from '@shared/models/beautify.models';
import { HttpStatusCode } from '@angular/common/http'; import { HttpClient, HttpStatusCode } from '@angular/common/http';
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;
import { TbEditorCompleter } from '@shared/models/ace/completion.models';
import { loadModulesCompleter } from '@shared/models/js-function.models';
import { TbPopoverService } from '@shared/components/popover.service';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { MatIconButton } from '@angular/material/button';
// @dynamic // @dynamic
@Component({ @Component({
@ -160,6 +165,7 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
dataKeyJsonSettingsEditor: Ace.Editor; dataKeyJsonSettingsEditor: Ace.Editor;
latestDataKeyJsonSettingsEditor: Ace.Editor; latestDataKeyJsonSettingsEditor: Ace.Editor;
jsEditor: Ace.Editor; jsEditor: Ace.Editor;
private initialCompleters: Ace.Completer[];
aceResize$: ResizeObserver; aceResize$: ResizeObserver;
onWindowMessageListener = this.onWindowMessage.bind(this); onWindowMessageListener = this.onWindowMessage.bind(this);
@ -187,7 +193,11 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
private widgetService: WidgetService, private widgetService: WidgetService,
private translate: TranslateService, private translate: TranslateService,
private raf: RafService, private raf: RafService,
private dialog: MatDialog) { private dialog: MatDialog,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private http: HttpClient) {
super(store); super(store);
this.authUser = getCurrentAuthUser(store); this.authUser = getCurrentAuthUser(store);
@ -387,15 +397,15 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
this.jsEditor = editor; this.jsEditor = editor;
this.jsEditor.on('input', () => { this.jsEditor.on('input', () => {
const editorValue = this.jsEditor.getValue(); const editorValue = this.jsEditor.getValue();
if (this.widget.controllerScript !== editorValue) { if (this.controllerScriptBody !== editorValue) {
this.widget.controllerScript = editorValue; this.controllerScriptBody = editorValue;
this.isDirty = true; this.isDirty = true;
} }
}); });
this.jsEditor.on('change', () => { this.jsEditor.on('change', () => {
this.cleanupJsErrors(); this.cleanupJsErrors();
}); });
this.jsEditor.completers = [widgetEditorCompleter, ...(this.jsEditor.completers || [])]; this.initialCompleters = this.jsEditor.completers || [];
}) })
)); ));
@ -414,7 +424,8 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
this.dataKeyJsonSettingsEditor.setValue(this.widget.dataKeySettingsSchema ? this.widget.dataKeySettingsSchema : '', -1); this.dataKeyJsonSettingsEditor.setValue(this.widget.dataKeySettingsSchema ? this.widget.dataKeySettingsSchema : '', -1);
this.latestDataKeyJsonSettingsEditor.setValue(this.widget.latestDataKeySettingsSchema ? this.latestDataKeyJsonSettingsEditor.setValue(this.widget.latestDataKeySettingsSchema ?
this.widget.latestDataKeySettingsSchema : '', -1); this.widget.latestDataKeySettingsSchema : '', -1);
this.jsEditor.setValue(this.widget.controllerScript ? this.widget.controllerScript : '', -1); this.jsEditor.setValue(this.controllerScriptBody ? this.controllerScriptBody : '', -1);
this.updateControllerScriptCompleters();
} }
private createAceEditor(editorElementRef: ElementRef, mode: string): Observable<Ace.Editor> { private createAceEditor(editorElementRef: ElementRef, mode: string): Observable<Ace.Editor> {
@ -781,12 +792,12 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
} }
beautifyJs(): void { beautifyJs(): void {
beautifyJs(this.widget.controllerScript, {indent_size: 4, wrap_line_length: 60}).subscribe( beautifyJs(this.controllerScriptBody, {indent_size: 4, wrap_line_length: 60}).subscribe(
(res) => { (res) => {
if (this.widget.controllerScript !== res) { if (this.controllerScriptBody !== res) {
this.isDirty = true; this.isDirty = true;
this.widget.controllerScript = res; this.controllerScriptBody = res;
this.jsEditor.setValue(this.widget.controllerScript ? this.widget.controllerScript : '', -1); this.jsEditor.setValue(this.controllerScriptBody ? this.controllerScriptBody : '', -1);
} }
} }
); );
@ -862,10 +873,90 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
this.isDirty = true; this.isDirty = true;
} }
editControllerScriptModules($event: Event, button: MatIconButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = button._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
modules: deepClone(this.controllerScriptModules)
};
const modulesPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, JsFuncModulesComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null,
ctx,
{},
{}, {}, true);
modulesPanelPopover.tbComponentRef.instance.popover = modulesPanelPopover;
modulesPanelPopover.tbComponentRef.instance.modulesApplied.subscribe((modules) => {
modulesPanelPopover.hide();
this.controllerScriptModules = modules;
this.updateControllerScriptCompleters();
this.isDirty = true;
});
}
}
get controllerScriptBody(): string {
if (typeof this.widget.controllerScript === 'string') {
return this.widget.controllerScript;
} else {
return this.widget.controllerScript.body;
}
}
set controllerScriptBody(controllerScriptBody) {
if (typeof this.widget.controllerScript === 'string') {
this.widget.controllerScript = controllerScriptBody;
} else {
this.widget.controllerScript.body = controllerScriptBody;
}
}
get controllerScriptModules(): {[alias: string]: string} {
if (typeof this.widget.controllerScript === 'string') {
return null;
} else {
return this.widget.controllerScript.modules;
}
}
set controllerScriptModules(modules: {[alias: string]: string}) {
if (modules && Object.keys(modules).length) {
if (typeof this.widget.controllerScript === 'string') {
this.widget.controllerScript = {
body: this.widget.controllerScript,
modules
};
} else {
this.widget.controllerScript.modules = modules;
}
} else {
if (typeof this.widget.controllerScript !== 'string') {
this.widget.controllerScript = this.widget.controllerScript.body;
}
}
}
get confirmOnExitMessage(): string { get confirmOnExitMessage(): string {
if (this.isEditModeWidget && !this._isDirty) { if (this.isEditModeWidget && !this._isDirty) {
return this.translate.instant('widget.confirm-to-exit-editor-html'); return this.translate.instant('widget.confirm-to-exit-editor-html');
} }
return ''; return '';
} }
private updateControllerScriptCompleters() {
const modulesCompleterObservable = loadModulesCompleter(this.http, this.controllerScriptModules);
modulesCompleterObservable.subscribe((modulesCompleter) => {
const completers: Ace.Completer[] = [];
completers.push(widgetEditorCompleter);
if (modulesCompleter) {
completers.push(modulesCompleter);
}
completers.push(...this.initialCompleters);
this.jsEditor.completers = completers;
});
}
} }

4
ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts

@ -85,9 +85,7 @@ const widgetEditorCompletions: TbEditorCompletions = {
}, },
...widgetContextCompletions ...widgetContextCompletions
} }
}}, }}
...widgetContextCompletions,
...serviceCompletions
}; };
export const widgetEditorCompleter = new TbEditorCompleter(widgetEditorCompletions); export const widgetEditorCompleter = new TbEditorCompleter(widgetEditorCompletions);

10
ui-ngx/src/app/shared/components/file-input.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div class="tb-container"> <div class="tb-container" [class.tb-button]="asButton">
<label class="tb-title" *ngIf="label" <label class="tb-title" *ngIf="label"
[class.tb-required]="!disabled && required" [class.tb-required]="!disabled && required"
[class.pointer-event]="hint" [class.pointer-event]="hint"
@ -38,11 +38,11 @@
flowDrop flowDrop
[flow]="flow.flowJs"> [flow]="flow.flowJs">
<div class="upload-label"> <div class="upload-label">
<mat-icon class="tb-mat-32">cloud_upload</mat-icon> <mat-icon class="tb-mat-32 drop-label-icon">cloud_upload</mat-icon>
<span>{{ dropLabel }}</span> <span class="drop-label-text">{{ dropLabel }}</span>
<button type="button" mat-button color="primary" class="browse-file"> <button type="button" mat-button color="primary" class="browse-file" [class]="uploadButtonClass">
<label <label
for="{{inputId}}">{{ (multipleFile ? 'file-input.browse-files' : 'file-input.browse-file') | translate}}</label> for="{{inputId}}">{{ uploadButtonText ? uploadButtonText : ((multipleFile ? 'file-input.browse-files' : 'file-input.browse-file') | translate) }}</label>
</button> </button>
<input class="file-input" flowButton #flowInput type="file" [flow]="flow.flowJs" <input class="file-input" flowButton #flowInput type="file" [flow]="flow.flowJs"
[flowAttributes]="{accept: accept}" id="{{inputId}}"> [flowAttributes]="{accept: accept}" id="{{inputId}}">

41
ui-ngx/src/app/shared/components/file-input.component.scss

@ -29,6 +29,37 @@ $previewSize: 100px !default;
display: flex; display: flex;
padding-bottom: 0; padding-bottom: 0;
} }
&.tb-button {
padding: 0;
gap: 0;
.tb-title {
display: none;
}
.tb-file-select-container {
width: auto;
height: auto;
.tb-file-clear-container {
display: none;
}
.tb-flow-drop {
height: auto;
border: none;
border-radius: 0;
.upload-label {
padding: 0;
.drop-label-icon {
display: none;
}
.drop-label-text {
display: none;
}
}
}
}
.tb-file-info-container {
display: none;
}
}
} }
.tb-file-select-container { .tb-file-select-container {
@ -114,10 +145,12 @@ $previewSize: 100px !default;
:host ::ng-deep { :host ::ng-deep {
button.mat-mdc-button.mat-mdc-button-base.browse-file { button.mat-mdc-button.mat-mdc-button-base.browse-file {
padding: 0; &:not(.tb-ignore-browse-file-button-style) {
min-width: 0; padding: 0;
height: 24px; min-width: 0;
font-size: 16px; height: 24px;
font-size: 16px;
}
label { label {
display: block; display: block;
cursor: pointer; cursor: pointer;

10
ui-ngx/src/app/shared/components/file-input.component.ts

@ -102,6 +102,16 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
@Input() @Input()
workFromFileObj = false; workFromFileObj = false;
@Input()
@coerceBoolean()
asButton: boolean;
@Input()
uploadButtonClass = 'browse-file';
@Input()
uploadButtonText: string;
private multipleFileValue = false; private multipleFileValue = false;
@Input() @Input()

15
ui-ngx/src/app/shared/components/help-markdown.component.ts

@ -21,10 +21,11 @@ import {
OnDestroy, OnInit, OnDestroy, OnInit,
Output, SimpleChanges Output, SimpleChanges
} from '@angular/core'; } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { share } from 'rxjs/operators'; import { share } from 'rxjs/operators';
import { HelpService } from '@core/services/help.service'; import { HelpService } from '@core/services/help.service';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { base64toString } from '@core/utils';
@Component({ @Component({
selector: 'tb-help-markdown', selector: 'tb-help-markdown',
@ -37,6 +38,10 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
@Input() helpContent: string; @Input() helpContent: string;
@Input() helpContentBase64: string;
@Input() asyncHelpContent: Observable<string>;
@Input() @Input()
@coerceBoolean() @coerceBoolean()
visible: boolean; visible: boolean;
@ -73,7 +78,7 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
this.loadHelp(); this.loadHelp();
} }
} }
if (propName === 'helpId' || propName === 'helpContent') { if (['helpId', 'helpContent', 'helpContentBase64', 'asyncHelpContent'].includes(propName)) {
this.markdownText.next(null); this.markdownText.next(null);
this.loadHelpWhenVisible(); this.loadHelpWhenVisible();
} }
@ -96,6 +101,12 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
}); });
} else if (this.helpContent) { } else if (this.helpContent) {
this.markdownText.next(this.helpContent); this.markdownText.next(this.helpContent);
} else if (this.helpContentBase64) {
this.markdownText.next(base64toString(this.helpContentBase64));
} else if (this.asyncHelpContent) {
this.asyncHelpContent.subscribe((content) => {
this.markdownText.next(content);
});
} }
} }

13
ui-ngx/src/app/shared/components/help-popup.component.html

@ -17,16 +17,20 @@
--> -->
<fieldset class="tb-help-popup-button-container" *ngIf="!textMode"> <fieldset class="tb-help-popup-button-container" *ngIf="!textMode">
<div #toggleHelpButton <div #toggleHelpButton
matTooltip="{{'help.show-help' | translate}}" matTooltip="{{ helpIconTooltip }}"
matTooltipPosition="above" matTooltipPosition="above"
style="border-radius: 50%" style="border-radius: 50%"
(click)="toggleHelp()"> (click)="toggleHelp()">
<button mat-icon-button <button mat-icon-button
[disabled]="disabled()"
color="primary" color="primary"
class="tb-help-popup-button tb-mat-32" class="tb-help-popup-button"
[class]="helpIconButtonClass"
type="button"> type="button">
<mat-icon class="material-icons">{{popoverVisible ? 'help' : 'help_outline'}}</mat-icon> <tb-icon matButtonIcon>{{popoverVisible ? helpOpenedIcon : helpIcon}}</tb-icon>
<mat-spinner *ngIf="popoverVisible && !popoverReady" class="tb-help-popup-button-loading" mode="indeterminate" diameter="20" strokeWidth="2"></mat-spinner> <div *ngIf="popoverVisible && !popoverReady" class="tb-help-popup-button-loading absolute inset-0 flex items-center justify-center">
<mat-spinner mode="indeterminate" diameter="20" strokeWidth="2"></mat-spinner>
</div>
</button> </button>
</div> </div>
</fieldset> </fieldset>
@ -34,6 +38,7 @@
<div #toggleHelpTextButton <div #toggleHelpTextButton
(click)="toggleHelp()"> (click)="toggleHelp()">
<button mat-button <button mat-button
[disabled]="disabled()"
type="button" type="button"
color="primary" color="primary"
class="tb-help-popup-text-button" class="tb-help-popup-text-button"

15
ui-ngx/src/app/shared/components/help-popup.component.scss

@ -22,21 +22,18 @@
} }
} }
.tb-help-popup-button-loading {
background: #fff;
border-radius: 50%;
z-index: 1;
}
.tb-help-popup-button { .tb-help-popup-button {
position: relative; position: relative;
.mat-mdc-progress-spinner { .mat-mdc-progress-spinner {
position: absolute;
top: 0;
left: 0;
background: #fff;
border-radius: 50%;
width: 32px !important;
height: 32px !important;
.mdc-circular-progress__indeterminate-container { .mdc-circular-progress__indeterminate-container {
width: 20px; width: 20px;
height: 20px; height: 20px;
top: 6px;
left: 6px;
} }
svg { svg {
width: 20px; width: 20px;

56
ui-ngx/src/app/shared/components/help-popup.component.ts

@ -29,9 +29,11 @@ import { PopoverPlacement } from '@shared/components/popover.models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { isDefinedAndNotNull } from '@core/utils'; import { isDefinedAndNotNull } from '@core/utils';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
@Component({ @Component({
selector: '[tb-help-popup], [tb-help-popup-content]', selector: '[tb-help-popup], [tb-help-popup-content], [tb-help-popup-content-base64], [tb-help-popup-async-content]',
templateUrl: './help-popup.component.html', templateUrl: './help-popup.component.html',
styleUrls: ['./help-popup.component.scss'], styleUrls: ['./help-popup.component.scss'],
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
@ -45,6 +47,22 @@ export class HelpPopupComponent implements OnChanges, OnDestroy {
@Input('tb-help-popup-content') helpContent: string; @Input('tb-help-popup-content') helpContent: string;
@Input('tb-help-popup-content-base64') helpContentBase64: string;
@Input('tb-help-popup-async-content') asyncHelpContent: () => Observable<string> | null;
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('help-icon') helpIcon = 'help_outline';
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('help-opened-icon') helpOpenedIcon = 'help';
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('help-icon-tooltip') helpIconTooltip = this.translate.instant('help.show-help');
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('help-icon-button-class') helpIconButtonClass = 'tb-mat-32';
// eslint-disable-next-line @angular-eslint/no-input-rename // eslint-disable-next-line @angular-eslint/no-input-rename
@Input('trigger-text') triggerText: string; @Input('trigger-text') triggerText: string;
@ -69,10 +87,10 @@ export class HelpPopupComponent implements OnChanges, OnDestroy {
textMode = false; textMode = false;
constructor(private viewContainerRef: ViewContainerRef, constructor(private viewContainerRef: ViewContainerRef,
private element: ElementRef<HTMLElement>,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
private renderer: Renderer2, private renderer: Renderer2,
private popoverService: TbPopoverService) { private popoverService: TbPopoverService,
private translate: TranslateService) {
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
@ -84,19 +102,27 @@ export class HelpPopupComponent implements OnChanges, OnDestroy {
this.textMode = this.triggerSafeHtml != null; this.textMode = this.triggerSafeHtml != null;
} }
disabled(): boolean {
return !this.helpId && !this.helpContent && !this.helpContentBase64 && !this.asyncHelpContent;
}
toggleHelp() { toggleHelp() {
const trigger = this.textMode ? this.toggleHelpTextButton.nativeElement : this.toggleHelpButton.nativeElement; if (!this.disabled()) {
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, const trigger = this.textMode ? this.toggleHelpTextButton.nativeElement : this.toggleHelpButton.nativeElement;
this.helpId, this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef,
this.helpContent, this.helpId,
(visible) => { this.helpContent,
this.popoverVisible = visible; this.helpContentBase64,
}, (ready => { this.asyncHelpContent ? this.asyncHelpContent() : null,
this.popoverReady = ready; (visible) => {
}), this.popoverVisible = visible;
this.helpPopupPlacement, }, (ready => {
{}, this.popoverReady = ready;
this.helpPopupStyle); }),
this.helpPopupPlacement,
{},
this.helpPopupStyle);
}
} }
ngOnDestroy(): void { ngOnDestroy(): void {

54
ui-ngx/src/app/shared/components/js-func-module-row.component.html

@ -0,0 +1,54 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div [formGroup]="moduleRowFormGroup" class="tb-form-table-row tb-js-func-module-row">
<mat-form-field class="tb-inline-field tb-alias-field" appearance="outline" subscriptSizing="dynamic">
<input required matInput formControlName="alias" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-resource-autocomplete class="tb-module-link-field"
#resourceAutocomplete
formControlName="moduleLink"
inlineField
hideRequiredMarker required
[subType]="ResourceSubType.MODULE"
[allowAutocomplete]="true"
placeholder="{{ 'widget-config.set' | translate }}">
</tb-resource-autocomplete>
<div class="tb-form-table-row-cell-buttons">
<div [tb-help-popup-async-content]="this.moduleRowFormGroup.get('moduleLink').value ? moduleDescription : null"
tb-help-popup-placement="top"
[tb-help-popup-style]="{marginTop: '8px'}"
help-icon-button-class=""
help-icon="info_outline"
help-opened-icon="info"
help-icon-tooltip="{{ 'js-func.show-module-info' | translate }}"></div>
<div [tb-help-popup-async-content]="this.moduleRowFormGroup.get('moduleLink').value ? moduleSourceCode : null"
tb-help-popup-placement="top"
[tb-help-popup-style]="{marginTop: '8px'}"
help-icon-button-class=""
help-icon="mdi:application-brackets-outline"
help-opened-icon="mdi:application-brackets"
help-icon-tooltip="{{ 'js-func.show-module-source-code' | translate }}"></div>
<button type="button"
mat-icon-button
(click)="moduleRemoved.emit()"
matTooltip="{{ 'js-func.remove-module' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>

23
ui-ngx/src/app/shared/components/js-func-module-row.component.scss

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.tb-js-func-module-row {
.tb-alias-field {
flex: 1 1 25%;
}
.tb-module-link-field {
flex: 1 1 75%;
}
}

190
ui-ngx/src/app/shared/components/js-func-module-row.component.ts

@ -0,0 +1,190 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
ChangeDetectorRef,
Component,
EventEmitter,
forwardRef,
Input,
OnInit,
Output, ViewChild,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator,
ValidatorFn,
Validators
} from '@angular/forms';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { ResourceSubType } from '@shared/models/resource.models';
import { Observable, of } from 'rxjs';
import { ResourceAutocompleteComponent } from '@shared/components/resource/resource-autocomplete.component';
import { HttpClient } from '@angular/common/http';
import { loadModuleMarkdownDescription, loadModuleMarkdownSourceCode } from '@shared/models/js-function.models';
import { TranslateService } from '@ngx-translate/core';
export interface JsFuncModuleRow {
alias: string;
moduleLink: string;
}
export const moduleValid = (module: JsFuncModuleRow): boolean => !(!module.alias || !module.moduleLink);
@Component({
selector: 'tb-js-func-module-row',
templateUrl: './js-func-module-row.component.html',
styleUrls: ['./js-func-module-row.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => JsFuncModuleRowComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => JsFuncModuleRowComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class JsFuncModuleRowComponent implements ControlValueAccessor, OnInit, Validator {
ResourceSubType = ResourceSubType;
@ViewChild('resourceAutocomplete')
resourceAutocomplete: ResourceAutocompleteComponent;
@Input()
index: number;
@Output()
moduleRemoved = new EventEmitter();
moduleRowFormGroup: UntypedFormGroup;
modelValue: JsFuncModuleRow;
moduleDescription = this.loadModuleDescription.bind(this);
moduleSourceCode = this.loadModuleSourceCode.bind(this);
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private cd: ChangeDetectorRef,
private modulesComponent: JsFuncModulesComponent,
private http: HttpClient,
private translate: TranslateService) {}
ngOnInit() {
this.moduleRowFormGroup = this.fb.group({
alias: [null, [this.moduleAliasValidator()]],
moduleLink: [null, [Validators.required]]
});
this.moduleRowFormGroup.valueChanges.subscribe(
() => this.updateModel()
);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
writeValue(value: JsFuncModuleRow): void {
this.modelValue = value;
this.moduleRowFormGroup.patchValue(
{
alias: value?.alias,
moduleLink: value?.moduleLink
}, {emitEvent: false}
);
this.cd.markForCheck();
}
public validate(_c: UntypedFormControl) {
const aliasControl = this.moduleRowFormGroup.get('alias');
if (aliasControl.hasError('moduleAliasNotUnique')) {
aliasControl.updateValueAndValidity({onlySelf: false, emitEvent: false});
}
if (aliasControl.hasError('moduleAliasNotUnique')) {
this.moduleRowFormGroup.get('alias').markAsTouched();
return {
moduleAliasNotUnique: true
};
}
const module: JsFuncModuleRow = {...this.modelValue, ...this.moduleRowFormGroup.value};
if (!moduleValid(module)) {
return {
module: true
};
}
return null;
}
private loadModuleDescription(): Observable<string> | null {
const moduleLink = this.moduleRowFormGroup.get('moduleLink').value;
if (moduleLink) {
const resource = this.resourceAutocomplete.resource;
return loadModuleMarkdownDescription(this.http, this.translate, resource);
} else {
return null;
}
}
private loadModuleSourceCode(): Observable<string> | null {
const moduleLink = this.moduleRowFormGroup.get('moduleLink').value;
if (moduleLink) {
const resource = this.resourceAutocomplete.resource;
return loadModuleMarkdownSourceCode(this.http, this.translate, resource);
} else {
return null;
}
}
private moduleAliasValidator(): ValidatorFn {
return control => {
if (!control.value) {
return {
required: true
};
}
if (!this.modulesComponent.moduleAliasUnique(control.value, this.index)) {
return {
moduleAliasNotUnique: true
};
}
return null;
};
}
private updateModel() {
const value: JsFuncModuleRow = this.moduleRowFormGroup.value;
this.modelValue = {...this.modelValue, ...value};
this.propagateChange(this.modelValue);
}
}

66
ui-ngx/src/app/shared/components/js-func-modules.component.html

@ -0,0 +1,66 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-js-func-modules-panel">
<div class="tb-js-func-modules-panel-title" translate>js-func.modules</div>
<div class="tb-js-func-modules-panel-content">
<div class="tb-form-panel no-border no-padding tb-js-func-modules">
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell tb-alias-header" translate>js-func.module-alias</div>
<div class="tb-form-table-header-cell tb-module-link-header" translate>js-func.module-resource</div>
<div class="tb-form-table-header-cell tb-actions-header"></div>
</div>
<div *ngIf="modulesFormArray().controls.length; else noModules" class="tb-form-table-body">
<div *ngFor="let moduleControl of modulesFormArray().controls; trackBy: trackByModule; let $index = index;">
<tb-js-func-module-row class="flex-1"
[index]="$index"
[formControl]="moduleControl"
(moduleRemoved)="removeModule($index)">
</tb-js-func-module-row>
</div>
</div>
<tb-error *ngIf="modulesFormGroup.hasError('moduleAliasNotUnique')"
noMargin [error]="'js-func.not-unique-module-aliases-error' | translate" style="padding-left: 12px;"></tb-error>
</div>
<div>
<button type="button" mat-stroked-button color="primary" (click)="addModule()">
{{ 'js-func.add-module' | translate }}
</button>
</div>
</div>
</div>
<div class="tb-js-func-modules-panel-buttons">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyModules()"
[disabled]="modulesFormGroup.invalid || !modulesFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>
<ng-template #noModules>
<span class="tb-prompt flex items-center justify-center">{{ 'js-func.no-modules' | translate }}</span>
</ng-template>

74
ui-ngx/src/app/shared/components/js-func-modules.component.scss

@ -0,0 +1,74 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../scss/constants';
.tb-js-func-modules-panel {
width: 540px;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-lt-md} {
width: 90vw;
}
.tb-js-func-modules-panel-content {
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
margin: -10px;
padding: 10px;
}
.tb-js-func-modules-panel-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-js-func-modules-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
.tb-js-func-modules {
flex: 1;
margin: 12px;
.tb-form-table-header-cell {
&.tb-alias-header {
flex: 1 1 25%;
}
&.tb-module-link-header {
flex: 1 1 75%;
}
&.tb-actions-header {
width: 120px;
min-width: 120px;
}
}
.tb-form-table {
overflow: hidden;
}
.tb-form-table-body {
overflow: auto;
tb-js-func-module-row {
overflow: hidden;
}
}
}
}

135
ui-ngx/src/app/shared/components/js-func-modules.component.ts

@ -0,0 +1,135 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, ValidatorFn } from '@angular/forms';
import { JsFuncModuleRow, moduleValid } from '@shared/components/js-func-module-row.component';
const modulesValidator: ValidatorFn = control => {
const modulesArray = control.get('modules') as UntypedFormArray;
const notUniqueControls =
modulesArray.controls.filter(moduleControl => moduleControl.hasError('moduleAliasNotUnique'));
if (notUniqueControls.length) {
return {
moduleAliasNotUnique: true
};
}
let valid = !modulesArray.controls.some(c => !c.valid);
valid = valid && control.valid;
return valid ? null : {
modules: {
valid: false,
},
};
};
@Component({
selector: 'tb-js-func-modules',
templateUrl: './js-func-modules.component.html',
styleUrls: ['./js-func-modules.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class JsFuncModulesComponent implements OnInit {
@Input()
modules: {[alias: string]: string };
@Input()
popover: TbPopoverComponent<JsFuncModulesComponent>;
@Output()
modulesApplied = new EventEmitter<{[alias: string]: string }>();
modulesFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder,
private cd: ChangeDetectorRef) {
}
ngOnInit(): void {
const modulesControls: Array<AbstractControl> = [];
if (this.modules && Object.keys(this.modules).length) {
Object.keys(this.modules).forEach((alias) => {
const moduleRow: JsFuncModuleRow = {
alias,
moduleLink: this.modules[alias]
};
modulesControls.push(this.fb.control(moduleRow, []));
});
}
this.modulesFormGroup = this.fb.group({
modules: this.fb.array(modulesControls)
}, {validators: modulesValidator});
}
cancel() {
this.popover?.hide();
}
applyModules() {
let moduleRows: JsFuncModuleRow[] = this.modulesFormGroup.get('modules').value;
if (moduleRows) {
moduleRows = moduleRows.filter(m => moduleValid(m));
}
if (moduleRows?.length) {
const modules: {[alias: string]: string } = {};
moduleRows.forEach(row => {
modules[row.alias] = row.moduleLink;
});
this.modulesApplied.emit(modules);
} else {
this.modulesApplied.emit(null);
}
}
public moduleAliasUnique(alias: string, index: number): boolean {
const modulesArray = this.modulesFormGroup.get('modules') as UntypedFormArray;
for (let i = 0; i < modulesArray.controls.length; i++) {
if (i !== index) {
const otherControl = modulesArray.controls[i];
if (alias === otherControl.value.alias) {
return false;
}
}
}
return true;
}
modulesFormArray(): UntypedFormArray {
return this.modulesFormGroup.get('modules') as UntypedFormArray;
}
trackByModule(_index: number, moduleControl: AbstractControl): any {
return moduleControl;
}
removeModule(index: number, emitEvent = true) {
(this.modulesFormGroup.get('modules') as UntypedFormArray).removeAt(index, {emitEvent});
}
addModule() {
const moduleRow: JsFuncModuleRow = {
alias: '',
moduleLink: ''
};
const modulesArray = this.modulesFormGroup.get('modules') as UntypedFormArray;
const moduleControl = this.fb.control(moduleRow, []);
modulesArray.push(moduleControl);
this.cd.detectChanges();
}
}

14
ui-ngx/src/app/shared/components/js-func.component.html

@ -19,12 +19,24 @@
tb-fullscreen tb-fullscreen
[fullscreen]="fullscreen"> [fullscreen]="fullscreen">
<div style="min-height: 40px;" class="tb-js-func-toolbar flex flex-row items-center justify-start"> <div style="min-height: 40px;" class="tb-js-func-toolbar flex flex-row items-center justify-start">
<label class="tb-title no-padding" <label *ngIf="!hideLabel" class="tb-title no-padding"
[class]="{'tb-error': !disabled && (hasErrors || !functionValid || required && !modelValue), 'tb-required': !disabled && required}"> [class]="{'tb-error': !disabled && (hasErrors || !functionValid || required && !modelValue), 'tb-required': !disabled && required}">
{{ functionLabel }} {{ functionLabel }}
</label> </label>
<ng-content select="[toolbarStartButton]"></ng-content>
<span class="flex-1"></span> <span class="flex-1"></span>
<ng-content select="[toolbarPrefixButton]"></ng-content> <ng-content select="[toolbarPrefixButton]"></ng-content>
<fieldset *ngIf="!disabled && withModules" style="width: initial">
<div matTooltip="{{'js-func.modules' | translate}}"
matTooltipPosition="above"
style="border-radius: 50%"
#editModulesButton
(click)="editModules($event, editModulesButton)">
<button type='button' mat-icon-button class="tb-mat-32">
<tb-icon color="primary" matButtonIcon>mdi:application-braces-outline</tb-icon>
</button>
</div>
</fieldset>
<button type='button' *ngIf="!disabled" mat-button class="tidy" (click)="beautifyJs()"> <button type='button' *ngIf="!disabled" mat-button class="tidy" (click)="beautifyJs()">
{{'js-func.tidy' | translate }} {{'js-func.tidy' | translate }}
</button> </button>

359
ui-ngx/src/app/shared/components/js-func.component.ts

@ -22,7 +22,9 @@ import {
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
Renderer2,
ViewChild, ViewChild,
ViewContainerRef,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormControl, Validator } from '@angular/forms'; import { ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormControl, Validator } from '@angular/forms';
@ -33,13 +35,19 @@ import { ActionNotificationHide, ActionNotificationShow } from '@core/notificati
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { guid, isUndefined } from '@app/core/utils'; import { deepClone, guid, isUndefined, isUndefinedOrNull } from '@app/core/utils';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
import { TbEditorCompleter } from '@shared/models/ace/completion.models'; import { TbEditorCompleter } from '@shared/models/ace/completion.models';
import { beautifyJs } from '@shared/models/beautify.models'; import { beautifyJs } from '@shared/models/beautify.models';
import { ScriptLanguage } from '@shared/models/rule-node.models'; import { ScriptLanguage } from '@shared/models/rule-node.models';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { compileTbFunction, loadModulesCompleter, TbFunction } from '@shared/models/js-function.models';
import { TbPopoverService } from '@shared/components/popover.service';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { HttpClient } from '@angular/common/http';
import { map, Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({ @Component({
selector: 'tb-js-func', selector: 'tb-js-func',
@ -65,6 +73,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
javascriptEditorElmRef: ElementRef; javascriptEditorElmRef: ElementRef;
private jsEditor: Ace.Editor; private jsEditor: Ace.Editor;
private initialCompleters: Ace.Completer[];
private editorsResizeCaf: CancelAnimationFrame; private editorsResizeCaf: CancelAnimationFrame;
private editorResize$: ResizeObserver; private editorResize$: ResizeObserver;
private ignoreChange = false; private ignoreChange = false;
@ -103,6 +112,14 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
@coerceBoolean() @coerceBoolean()
hideBrackets = false; hideBrackets = false;
@Input()
@coerceBoolean()
hideLabel = false;
@Input()
@coerceBoolean()
withModules = false;
private noValidateValue: boolean; private noValidateValue: boolean;
get noValidate(): boolean { get noValidate(): boolean {
return this.noValidateValue; return this.noValidateValue;
@ -127,6 +144,8 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
modelValue: string; modelValue: string;
modules: {[alias: string]: string };
functionValid = true; functionValid = true;
validationError: string; validationError: string;
@ -139,6 +158,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
private functionArgsString = ''; private functionArgsString = '';
private propagateChange = null; private propagateChange = null;
private _onTouched = null;
public hasErrors = false; public hasErrors = false;
constructor(public elementRef: ElementRef, constructor(public elementRef: ElementRef,
@ -146,7 +166,11 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
private translate: TranslateService, private translate: TranslateService,
protected store: Store<AppState>, protected store: Store<AppState>,
private raf: RafService, private raf: RafService,
private cd: ChangeDetectorRef) { private cd: ChangeDetectorRef,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private http: HttpClient) {
} }
ngOnInit(): void { ngOnInit(): void {
@ -200,6 +224,11 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
this.updateView(); this.updateView();
} }
}); });
this.jsEditor.on('blur', () => {
if (this._onTouched) {
this._onTouched();
}
});
if (!this.disableUndefinedCheck) { if (!this.disableUndefinedCheck) {
// @ts-ignore // @ts-ignore
this.jsEditor.session.on('changeAnnotation', () => { this.jsEditor.session.on('changeAnnotation', () => {
@ -211,7 +240,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
const hasErrors = annotations.filter(annotation => annotation.type === 'error').length > 0; const hasErrors = annotations.filter(annotation => annotation.type === 'error').length > 0;
if (this.hasErrors !== hasErrors) { if (this.hasErrors !== hasErrors) {
this.hasErrors = hasErrors; this.hasErrors = hasErrors;
this.propagateChange(this.modelValue); this.propagateValue(this.modelValue);
this.cd.markForCheck(); this.cd.markForCheck();
} }
}); });
@ -231,29 +260,9 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
// @ts-ignore // @ts-ignore
this.jsEditor.session.$onChangeMode(newMode); this.jsEditor.session.$onChangeMode(newMode);
} }
// @ts-ignore this.updateJsWorkerGlobals();
if (!!this.jsEditor.session.$worker) { this.initialCompleters = this.jsEditor.completers || [];
const jsWorkerOptions = { this.updateCompleters();
undef: !this.disableUndefinedCheck,
unused: true,
globals: {}
};
if (!this.disableUndefinedCheck && this.functionArgs) {
this.functionArgs.forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
if (!this.disableUndefinedCheck && this.globalVariables) {
this.globalVariables.forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
// @ts-ignore
this.jsEditor.session.$worker.send('changeOptions', [jsWorkerOptions]);
}
if (this.editorCompleter) {
this.jsEditor.completers = [this.editorCompleter, ...(this.jsEditor.completers || [])];
}
this.editorResize$ = new ResizeObserver(() => { this.editorResize$ = new ResizeObserver(() => {
this.onAceEditorResize(); this.onAceEditorResize();
}); });
@ -287,6 +296,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
} }
registerOnTouched(fn: any): void { registerOnTouched(fn: any): void {
this._onTouched = fn;
} }
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {
@ -313,23 +323,29 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
); );
} }
validateOnSubmit(): void { validateOnSubmit(): Observable<void> {
if (!this.disabled) { if (!this.disabled) {
this.cleanupJsErrors(); this.cleanupJsErrors();
this.functionValid = this.validateJsFunc(); return this.validateJsFunc().pipe(
if (!this.functionValid) { map((valid) => {
this.propagateChange(this.modelValue); this.functionValid = valid;
this.cd.markForCheck(); if (!this.functionValid) {
this.store.dispatch(new ActionNotificationShow( this.propagateValue(this.modelValue);
{ this.cd.markForCheck();
message: this.validationError, this.store.dispatch(new ActionNotificationShow(
type: 'error', {
target: this.toastTargetId, message: this.validationError,
verticalPosition: 'bottom', type: 'error',
horizontalPosition: 'left' target: this.toastTargetId,
})); verticalPosition: 'bottom',
this.errorShowed = true; horizontalPosition: 'left'
} }));
this.errorShowed = true;
}
})
);
} else {
return of(null);
} }
} }
@ -338,83 +354,95 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
this.jsEditor?.focus(); this.jsEditor?.focus();
} }
private validateJsFunc(): boolean { private validateJsFunc(): Observable<boolean> {
try { let toCompile: TbFunction;
const toValidate = new Function(this.functionArgsString, this.modelValue); if (this.withModules && this.modules && Object.keys(this.modules).length) {
if (this.noValidate) { toCompile = {
return true; body: this.modelValue,
} modules: this.modules
if (this.validationArgs) { };
let res: any; } else {
let validationError: any; toCompile = this.modelValue;
for (const validationArg of this.validationArgs) { }
try { const args = this.functionArgs || [];
res = toValidate.apply(this, validationArg); return compileTbFunction(this.http, toCompile, ...args).pipe(
validationError = null; map(toValidate => {
break; if (this.noValidate) {
} catch (e) { return true;
validationError = e;
}
}
if (validationError) {
throw validationError;
} }
if (this.resultType !== 'nocheck') { if (this.validationArgs) {
if (this.resultType === 'any') { let res: any;
if (isUndefined(res)) { let validationError: any;
this.validationError = this.translate.instant('js-func.no-return-error'); for (const validationArg of this.validationArgs) {
return false; try {
res = toValidate.apply(this, validationArg);
validationError = null;
break;
} catch (e) {
validationError = e;
} }
} else { }
const resType = typeof res; if (validationError) {
if (resType !== this.resultType) { throw validationError;
this.validationError = this.translate.instant('js-func.return-type-mismatch', {type: this.resultType}); }
return false; if (this.resultType !== 'nocheck') {
if (this.resultType === 'any') {
if (isUndefined(res)) {
this.validationError = this.translate.instant('js-func.no-return-error');
return false;
}
} else {
const resType = typeof res;
if (resType !== this.resultType) {
this.validationError = this.translate.instant('js-func.return-type-mismatch', {type: this.resultType});
return false;
}
} }
} }
return true;
} else {
return true;
} }
return true; }),
} else { catchError((e) => {
return true; const details = this.utils.parseException(e);
} let errorInfo = 'Error:';
} catch (e) { if (details.name) {
const details = this.utils.parseException(e); errorInfo += ' ' + details.name + ':';
let errorInfo = 'Error:';
if (details.name) {
errorInfo += ' ' + details.name + ':';
}
if (details.message) {
errorInfo += ' ' + details.message;
}
if (details.lineNumber) {
errorInfo += '<br>Line ' + details.lineNumber;
if (details.columnNumber) {
errorInfo += ' column ' + details.columnNumber;
} }
errorInfo += ' of script.'; if (details.message) {
} errorInfo += ' ' + details.message;
this.validationError = errorInfo;
if (details.lineNumber) {
const line = details.lineNumber - 1;
let column = 0;
if (details.columnNumber) {
column = details.columnNumber;
} }
const errorMarkerId = this.jsEditor.session.addMarker(new Range(line, 0, line, Infinity), if (details.lineNumber) {
'ace_active-line', 'screenLine'); errorInfo += '<br>Line ' + details.lineNumber;
this.errorMarkers.push(errorMarkerId); if (details.columnNumber) {
const annotations = this.jsEditor.session.getAnnotations(); errorInfo += ' column ' + details.columnNumber;
const errorAnnotation: Ace.Annotation = { }
row: line, errorInfo += ' of script.';
column, }
text: details.message, this.validationError = errorInfo;
type: 'error' if (details.lineNumber) {
}; const line = details.lineNumber - 1;
this.errorAnnotationId = annotations.push(errorAnnotation) - 1; let column = 0;
this.jsEditor.session.setAnnotations(annotations); if (details.columnNumber) {
} column = details.columnNumber;
return false; }
} const errorMarkerId = this.jsEditor.session.addMarker(new Range(line, 0, line, Infinity),
'ace_active-line', 'screenLine');
this.errorMarkers.push(errorMarkerId);
const annotations = this.jsEditor.session.getAnnotations();
const errorAnnotation: Ace.Annotation = {
row: line,
column,
text: details.message,
type: 'error'
};
this.errorAnnotationId = annotations.push(errorAnnotation) - 1;
this.jsEditor.session.setAnnotations(annotations);
}
return of(false);
})
);
} }
private cleanupJsErrors(): void { private cleanupJsErrors(): void {
@ -437,22 +465,121 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
} }
} }
writeValue(value: string): void { writeValue(value: TbFunction): void {
this.modelValue = value; if (isUndefinedOrNull(value) || typeof value === 'string') {
this.modelValue = value as any;
this.modules = null;
} else {
this.modelValue = value.body;
this.modules = value.modules;
}
if (this.jsEditor) { if (this.jsEditor) {
if (this.withModules) {
this.updateJsWorkerGlobals();
this.updateCompleters();
}
this.ignoreChange = true; this.ignoreChange = true;
this.jsEditor.setValue(this.modelValue ? this.modelValue : '', -1); this.jsEditor.setValue(this.modelValue ? this.modelValue : '', -1);
this.ignoreChange = false; this.ignoreChange = false;
} }
} }
updateView() { updateView(force = false) {
const editorValue = this.jsEditor.getValue(); const editorValue = this.jsEditor.getValue();
if (this.modelValue !== editorValue) { if (this.modelValue !== editorValue || force) {
this.modelValue = editorValue; this.modelValue = editorValue;
this.functionValid = true; this.functionValid = true;
this.propagateChange(this.modelValue); this.propagateValue(this.modelValue);
this.cd.markForCheck(); this.cd.markForCheck();
} }
} }
editModules($event: Event, element: Element) {
if ($event) {
$event.stopPropagation();
}
const trigger = element;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
modules: deepClone(this.modules)
};
const modulesPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, JsFuncModulesComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null,
ctx,
{},
{}, {}, true);
modulesPanelPopover.tbComponentRef.instance.popover = modulesPanelPopover;
modulesPanelPopover.tbComponentRef.instance.modulesApplied.subscribe((modules) => {
modulesPanelPopover.hide();
this.modules = modules;
this.updateJsWorkerGlobals();
this.updateCompleters();
this.updateView(true);
});
}
}
private propagateValue(value: string) {
if (this.withModules && this.modules && Object.keys(this.modules).length) {
const tbFunction: TbFunction = {
body: value,
modules: this.modules
};
this.propagateChange(tbFunction);
} else {
this.propagateChange(value);
}
}
private updateJsWorkerGlobals() {
// @ts-ignore
if (!!this.jsEditor.session.$worker) {
const jsWorkerOptions = {
undef: !this.disableUndefinedCheck,
unused: true,
globals: {}
};
if (!this.disableUndefinedCheck) {
if (this.functionArgs) {
this.functionArgs.forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
if (this.withModules && this.modules) {
Object.keys(this.modules).forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
if (this.globalVariables) {
this.globalVariables.forEach(arg => {
jsWorkerOptions.globals[arg] = false;
});
}
}
// @ts-ignore
this.jsEditor.session.$worker.send('changeOptions', [jsWorkerOptions]);
}
}
updateCompleters() {
let modulesCompleterObservable: Observable<TbEditorCompleter>;
if (this.withModules) {
modulesCompleterObservable = loadModulesCompleter(this.http, this.modules);
} else {
modulesCompleterObservable = of(null);
}
modulesCompleterObservable.subscribe((modulesCompleter) => {
const completers: Ace.Completer[] = [];
if (this.editorCompleter) {
completers.push(this.editorCompleter);
}
if (modulesCompleter) {
completers.push(modulesCompleter);
}
completers.push(...this.initialCompleters);
this.jsEditor.completers = completers;
});
}
} }

2
ui-ngx/src/app/shared/components/json-form/json-form.component.ts

@ -243,7 +243,7 @@ export class JsonFormComponent implements ControlValueAccessor, Validator, OnCha
private onHelpClick(event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void, helpReadyFn: (ready: boolean) => void) { private onHelpClick(event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void, helpReadyFn: (ready: boolean) => void) {
const trigger = event.currentTarget as Element; const trigger = event.currentTarget as Element;
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, helpId, '', helpVisibleFn, helpReadyFn); this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, helpId, '', '', null, helpVisibleFn, helpReadyFn);
} }
private updateAndRender() { private updateAndRender() {

5
ui-ngx/src/app/shared/components/popover.service.ts

@ -29,6 +29,7 @@ import { TbPopoverComponent } from '@shared/components/popover.component';
import { ComponentType } from '@angular/cdk/portal'; import { ComponentType } from '@angular/cdk/portal';
import { HELP_MARKDOWN_COMPONENT_TOKEN } from '@shared/components/tokens'; import { HELP_MARKDOWN_COMPONENT_TOKEN } from '@shared/components/tokens';
import { CdkOverlayOrigin } from '@angular/cdk/overlay'; import { CdkOverlayOrigin } from '@angular/cdk/overlay';
import { Observable } from 'rxjs';
@Injectable() @Injectable()
export class TbPopoverService { export class TbPopoverService {
@ -113,6 +114,8 @@ export class TbPopoverService {
toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId = '', toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId = '',
helpContent = '', helpContent = '',
helpContentBase64 = '',
asyncHelpContent: Observable<string> = null,
visibleFn: (visible: boolean) => void = () => {}, visibleFn: (visible: boolean) => void = () => {},
readyFn: (ready: boolean) => void = () => {}, readyFn: (ready: boolean) => void = () => {},
preferredPlacement: PopoverPreferredPlacement = 'bottom', preferredPlacement: PopoverPreferredPlacement = 'bottom',
@ -144,6 +147,8 @@ export class TbPopoverService {
component.tbComponentContext = { component.tbComponentContext = {
helpId, helpId,
helpContent, helpContent,
helpContentBase64,
asyncHelpContent,
style: helpStyle, style: helpStyle,
visible: true visible: true
}; };

8
ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.html

@ -15,10 +15,12 @@
limitations under the License. limitations under the License.
--> -->
<mat-form-field [formGroup]="resourceFormGroup" class="mat-block" <mat-form-field [formGroup]="resourceFormGroup"
[appearance]="appearance" [class]="{'tb-inline-field': inlineField, 'flex': inlineField}"
class="mat-block"
[appearance]="inlineField ? 'outline' : appearance"
[hideRequiredMarker]="hideRequiredMarker" [hideRequiredMarker]="hideRequiredMarker"
[subscriptSizing]="subscriptSizing"> [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing">
<input matInput type="text" <input matInput type="text"
#resourceInput #resourceInput
formControlName="resource" formControlName="resource"

22
ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts

@ -26,6 +26,7 @@ import {
prependTbResourcePrefix, prependTbResourcePrefix,
removeTbResourcePrefix, removeTbResourcePrefix,
ResourceInfo, ResourceInfo,
ResourceSubType,
ResourceType ResourceType
} from '@shared/models/resource.models'; } from '@shared/models/resource.models';
import { TbResourceId } from '@shared/models/id/tb-resource-id'; import { TbResourceId } from '@shared/models/id/tb-resource-id';
@ -59,6 +60,10 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
@Input() @Input()
subscriptSizing: SubscriptSizing = 'fixed'; subscriptSizing: SubscriptSizing = 'fixed';
@Input()
@coerceBoolean()
inlineField: boolean;
@Input() @Input()
placeholder: string; placeholder: string;
@ -70,6 +75,9 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
@coerceBoolean() @coerceBoolean()
allowAutocomplete = false; allowAutocomplete = false;
@Input()
subType = ResourceSubType.EXTENSION;
resourceFormGroup = this.fb.group({ resourceFormGroup = this.fb.group({
resource: this.fb.control<string|ResourceInfo>(null) resource: this.fb.control<string|ResourceInfo>(null)
}); });
@ -80,6 +88,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
@ViewChild('resourceInput', {static: true}) resourceInput: ElementRef; @ViewChild('resourceInput', {static: true}) resourceInput: ElementRef;
resource: ResourceInfo;
private modelValue: string; private modelValue: string;
private dirty = false; private dirty = false;
@ -100,10 +109,13 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
tap(value => { tap(value => {
let modelValue: string; let modelValue: string;
if (isObject(value)) { if (isObject(value)) {
modelValue = prependTbResourcePrefix((value as ResourceInfo).link); this.resource = value as ResourceInfo;
} else if (isEmptyStr(value)) { modelValue = prependTbResourcePrefix(this.resource.link);
} else if (isEmptyStr(value) || this.subType !== ResourceSubType.EXTENSION) {
this.resource = null;
modelValue = null; modelValue = null;
} else { } else {
this.resource = null;
modelValue = value as string; modelValue = value as string;
} }
this.updateView(modelValue); this.updateView(modelValue);
@ -139,10 +151,12 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
if (isObject(value) && typeof value !== 'string' && (value as TbResourceId).id) { if (isObject(value) && typeof value !== 'string' && (value as TbResourceId).id) {
this.resourceService.getResourceInfoById(value.id, {ignoreLoading: true, ignoreErrors: true}).subscribe({ this.resourceService.getResourceInfoById(value.id, {ignoreLoading: true, ignoreErrors: true}).subscribe({
next: resource => { next: resource => {
this.resource = resource;
this.modelValue = prependTbResourcePrefix(resource.link); this.modelValue = prependTbResourcePrefix(resource.link);
this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false}); this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false});
}, },
error: () => { error: () => {
this.resource = null;
this.modelValue = ''; this.modelValue = '';
this.resourceFormGroup.get('resource').patchValue(''); this.resourceFormGroup.get('resource').patchValue('');
} }
@ -152,10 +166,12 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
const params = extractParamsFromJSResourceUrl(url); const params = extractParamsFromJSResourceUrl(url);
this.resourceService.getResourceInfo(params.type, params.scope, params.key, {ignoreLoading: true, ignoreErrors: true}).subscribe({ this.resourceService.getResourceInfo(params.type, params.scope, params.key, {ignoreLoading: true, ignoreErrors: true}).subscribe({
next: resource => { next: resource => {
this.resource = resource;
this.modelValue = value; this.modelValue = value;
this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false}); this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false});
}, },
error: () => { error: () => {
this.resource = null;
this.modelValue = ''; this.modelValue = '';
this.resourceFormGroup.get('resource').patchValue(''); this.resourceFormGroup.get('resource').patchValue('');
} }
@ -196,7 +212,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
private fetchResources(searchText?: string): Observable<Array<ResourceInfo>> { private fetchResources(searchText?: string): Observable<Array<ResourceInfo>> {
this.searchText = searchText; this.searchText = searchText;
return this.resourceService.getResources(new PageLink(50, 0, searchText), ResourceType.JS_MODULE, {ignoreLoading: true}).pipe( return this.resourceService.getResources(new PageLink(50, 0, searchText), ResourceType.JS_MODULE, this.subType, {ignoreLoading: true}).pipe(
catchError(() => of(null)), catchError(() => of(null)),
map(data => data.data) map(data => data.data)
); );

2
ui-ngx/src/app/shared/models/ace/completion.models.ts

@ -17,7 +17,7 @@
import { Ace } from 'ace-builds'; import { Ace } from 'ace-builds';
import { deepClone } from '@core/utils'; import { deepClone } from '@core/utils';
export type tbMetaType = 'object' | 'function' | 'service' | 'property' | 'argument'; export type tbMetaType = 'object' | 'function' | 'service' | 'property' | 'argument' | 'constant' | 'module';
export type TbEditorCompletions = {[name: string]: TbEditorCompletion}; export type TbEditorCompletions = {[name: string]: TbEditorCompletion};

47
ui-ngx/src/app/shared/models/error.models.ts

@ -15,9 +15,56 @@
/// ///
import { isUndefined } from '@core/utils';
import { WidgetContext } from '@home/models/widget-component.models';
import { UtilsService } from '@core/services/utils.service';
export interface ExceptionData { export interface ExceptionData {
message?: string; message?: string;
name?: string; name?: string;
lineNumber?: number; lineNumber?: number;
columnNumber?: number; columnNumber?: number;
} }
export const parseException = (exception: any, lineOffset?: number): ExceptionData => {
const data: ExceptionData = {};
if (exception) {
if (typeof exception === 'string') {
data.message = exception;
} else if (exception instanceof String) {
data.message = exception.toString();
} else {
if (exception.name) {
data.name = exception.name;
} else {
data.name = 'UnknownError';
}
if (exception.message) {
data.message = exception.message;
}
if (exception.lineNumber) {
data.lineNumber = exception.lineNumber;
if (exception.columnNumber) {
data.columnNumber = exception.columnNumber;
}
} else if (exception.stack) {
const lineInfoRegexp = /(.*<anonymous>):(\d*)(:)?(\d*)?/g;
const lineInfoGroups = lineInfoRegexp.exec(exception.stack);
if (lineInfoGroups != null && lineInfoGroups.length >= 3) {
if (isUndefined(lineOffset)) {
lineOffset = -2;
}
data.lineNumber = Number(lineInfoGroups[2]) + lineOffset;
if (lineInfoGroups.length >= 5) {
data.columnNumber = Number(lineInfoGroups[4]);
}
}
}
}
}
return data;
}
export const parseError = (err: any): string =>
parseException(err).message || 'Unknown Error';

310
ui-ngx/src/app/shared/models/js-function.models.ts

@ -0,0 +1,310 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { forkJoin, from, map, mergeMap, Observable, of, ReplaySubject, switchMap } from 'rxjs';
import { removeTbResourcePrefix, ResourceInfo } from '@shared/models/resource.models';
import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig } from '@core/http/http-utils';
import { TbEditorCompleter, TbEditorCompletion } from '@shared/models/ace/completion.models';
import { blobToText } from '@core/utils';
import { catchError, finalize } from 'rxjs/operators';
import { parseError } from '@shared/models/error.models';
import { TranslateService } from '@ngx-translate/core';
export interface TbFunctionWithModules {
body: string;
modules: {[alias: string]: string };
}
export type TbFunction = string | TbFunctionWithModules;
export const isNotEmptyTbFunction = (tbFunction: TbFunction): boolean => {
if (tbFunction) {
if (typeof tbFunction === 'string') {
return tbFunction.trim().length > 0;
} else {
return tbFunction.body && tbFunction.body.trim().length > 0;
}
} else {
return false;
}
}
export const compileTbFunction = <T extends GenericFunction>(http: HttpClient, tbFunction: TbFunction, ...args: string[]): Observable<CompiledTbFunction<T>> => {
let functionBody: string;
let functionArgs: string[];
let modules: {[alias: string]: string };
if (typeof tbFunction === 'string') {
functionBody = tbFunction;
functionArgs = args;
} else {
functionBody = tbFunction.body;
modules = tbFunction.modules;
const modulesArgs = Object.keys(tbFunction.modules);
functionArgs = args.concat(modulesArgs);
}
return loadFunctionModules(http, modules).pipe(
map((compiledModules) => {
const compiledFunction = new Function(...functionArgs, functionBody);
return new CompiledTbFunction<T>(compiledFunction, compiledModules);
})
);
}
export const loadModulesCompleter = (http: HttpClient, modules: {[alias: string]: string }): Observable<TbEditorCompleter | null> => {
if (!modules || !Object.keys(modules).length) {
return of(null);
} else {
const modulesDescription: {[alias: string]: Observable<TbEditorCompletion>} = {};
for (const alias of Object.keys(modules)) {
modulesDescription[alias] = loadModuleCompletion(http, modules[alias]);
}
return forkJoin(modulesDescription).pipe(
map((completions) => {
return new TbEditorCompleter(completions);
})
);
}
};
export const loadModuleMarkdownDescription = (http: HttpClient, translate: TranslateService, resource: ResourceInfo): Observable<string> => {
let description = `<div class="flex flex-col !pl-4 !pr-4"><h6>${resource.title}</h6><small>${translate.instant('js-func.module-members')}</small></div>\n\n`;
description += '<div class="divider !pt-2"></div>\n' +
'<br/>\n\n';
return loadFunctionModuleWithSource(http, resource.link).pipe(
map((moduleWithSource) => {
const module = moduleWithSource.module;
const propertiesData: { type: 'function' | 'const', propName: string, description: string }[] = [];
for (const propName of Object.keys(module)) {
let propDescription = '';
const prop = module[propName];
const type = typeof prop;
if (type === 'function') {
const funcArgs = getFunctionArguments(prop);
propDescription += `<p class="!pl-4 !pr-4"><em>function</em> <strong>${propName}</strong> <em>(${funcArgs.join(', ')})</em>: <code>any</code></p>`;
} else {
propDescription += `<p class="!pl-4 !pr-4"><em>const</em> <strong>${propName}</strong>: <code>${type}</code>`;
if (type !== 'object') {
propDescription += ` = ${prop}`;
}
propDescription += '</p>';
}
propDescription += '\n\n';
const propertyData: { type: 'function' | 'const', propName: string, description: string } = {
type: type === 'function' ? 'function' : 'const',
propName,
description: propDescription
}
propertiesData.push(propertyData);
}
propertiesData.sort((a, b) => {
if (a.type === b.type) {
return a.propName.localeCompare(b.propName);
} else if (a.type === 'const') return -1;
else return 1;
});
if (!propertiesData.length) {
description += `<div class="!pl-4 !pr-4">${translate.instant('js-func.module-no-members')}</div>\n\n`;
} else {
propertiesData.forEach((pData) => {
description += pData.description;
});
}
return description;
}),
catchError(err => {
const errorText = parseError(err);
description += `<div class="!pl-4 !pr-4">${translate.instant('js-func.module-load-error')}:<br/><span style="color: red;">${errorText}</span></div>\n\n`;
return of(description);
})
);
}
export const loadModuleMarkdownSourceCode = (http: HttpClient, translate: TranslateService, resource: ResourceInfo): Observable<string> => {
let sourceCode = `<div class="flex flex-col !pl-4"><h6>${resource.title}</h6><small>${translate.instant('js-func.source-code')}</small></div>\n\n`;
return loadFunctionModuleSource(http, resource.link).pipe(
map((source) => {
sourceCode += '```javascript\n{:code-style="margin-left: -16px; margin-right: -16px;"}\n' + source + '\n```';
return sourceCode;
}),
catchError(err => {
const errorText = parseError(err);
sourceCode += `<div class="!pl-4 !pr-4">${translate.instant('js-func.source-code-load-error')}:<br/><span style="color: red;">${errorText}</span></div>\n\n`;
return of(sourceCode);
})
);
}
const loadModuleCompletion = (http: HttpClient, moduleLink: string): Observable<TbEditorCompletion> => {
return loadFunctionModule(http, moduleLink).pipe(
map((module) => {
const completion: TbEditorCompletion = {
meta: 'module',
type: 'module',
children: {}
};
for (const propName of Object.keys(module)) {
const prop = module[propName];
const type = typeof prop;
const propertyCompletion: TbEditorCompletion = {
meta: type === 'function' ? 'function' : 'constant',
type
};
if (type === 'function') {
propertyCompletion.args = getFunctionArguments(prop).map(functionArg => {
return {name: functionArg}
});
propertyCompletion.return = { type: 'any'};
} else if (type !== 'object') {
propertyCompletion.description = `<div class="tb-api-title">Constant value:</div><code class="title">${prop}</code>`;
}
completion.children[propName] = propertyCompletion;
}
return completion;
}),
catchError(err => {
const completion: TbEditorCompletion = {
meta: 'module',
type: 'module',
children: {}
};
const errorText = parseError(err);
completion.description = `<div>Module load error:<br/><span style="color: red;">${errorText}</span></div>`;
return of(completion);
})
);
}
export type GenericFunction = (...args: any[]) => any;
export class CompiledTbFunction<T extends GenericFunction> {
public execute: T = this.executeImpl.bind(this);
constructor(private compiledFunction: Function,
private compiledModules: System.Module[]) {
}
private executeImpl(...args: any[]): any {
let functionArgs: any[];
if (this.compiledModules?.length) {
functionArgs = args ? args.concat(this.compiledModules) : this.compiledModules;
} else {
functionArgs = args;
}
return this.compiledFunction(...functionArgs);
}
apply(thisArg: any, argArray?: any): any {
let functionArgs: any[];
if (this.compiledModules?.length) {
functionArgs = argArray ? argArray.concat(this.compiledModules) : this.compiledModules;
} else {
functionArgs = argArray;
}
return this.compiledFunction.apply(thisArg, functionArgs);
}
}
const loadFunctionModules = (http: HttpClient, modules: {[alias: string]: string }): Observable<System.Module[]> => {
if (modules && Object.keys(modules).length) {
const moduleObservables: Observable<System.Module>[] = [];
for (const alias of Object.keys(modules)) {
moduleObservables.push(loadFunctionModule(http, modules[alias]));
}
return forkJoin(moduleObservables);
} else {
return of([]);
}
}
const modulesLoading: {[url: string]: ReplaySubject<System.Module>} = {};
const loadFunctionModule = (http: HttpClient, moduleLink: string): Observable<System.Module> => {
const url = removeTbResourcePrefix(moduleLink);
let request: ReplaySubject<System.Module>;
if (modulesLoading[url]) {
request = modulesLoading[url];
} else {
request = new ReplaySubject<System.Module>(1);
modulesLoading[url] = request;
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
http.get(url, {...options, ...{ observe: 'response', responseType: 'blob' } }).pipe(
mergeMap((response) => {
const objectURL = URL.createObjectURL(response.body);
const asyncModule = from(import(/* @vite-ignore */objectURL));
URL.revokeObjectURL(objectURL);
return asyncModule;
}),
finalize(() => {
delete modulesLoading[url];
})
).subscribe(
{
next: (value) => {
request.next(value);
request.complete();
},
error: err => {
request.error(err);
}
}
);
}
return request;
}
interface TbModuleWithSource {
module: System.Module;
source: string;
}
const loadFunctionModuleWithSource = (http: HttpClient, moduleLink: string): Observable<TbModuleWithSource> => {
const url = removeTbResourcePrefix(moduleLink);
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
return http.get(url, {...options, ...{ observe: 'response', responseType: 'blob' } }).pipe(
switchMap((response) => {
const objectURL = URL.createObjectURL(response.body);
const asyncModule = from(import(/* @vite-ignore */objectURL));
URL.revokeObjectURL(objectURL);
const asyncSource = blobToText(response.body);
return forkJoin({
module: asyncModule,
source: asyncSource
});
}));
}
const loadFunctionModuleSource = (http: HttpClient, moduleLink: string): Observable<string> => {
const url = removeTbResourcePrefix(moduleLink);
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
return http.get(url, {...options, ...{ responseType: 'text' } });
}
const getFunctionArguments = (func: Function): string[] => {
const fnStr = func.toString().replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg, '');
const firstBracketIndex = fnStr.indexOf('(');
const secondBracketIndex = fnStr.indexOf(')');
if (firstBracketIndex === -1 || secondBracketIndex === -1 || (secondBracketIndex - firstBracketIndex) <= 1) {
return [];
}
const match = fnStr.slice(firstBracketIndex+1, secondBracketIndex).match(/([^\s,]+)/g);
if (match) {
return new Array<string>(...match);
} else {
return [];
}
}

13
ui-ngx/src/app/shared/models/resource.models.ts

@ -29,7 +29,9 @@ export enum ResourceType {
export enum ResourceSubType { export enum ResourceSubType {
IMAGE = 'IMAGE', IMAGE = 'IMAGE',
SCADA_SYMBOL = 'SCADA_SYMBOL' SCADA_SYMBOL = 'SCADA_SYMBOL',
EXTENSION = 'EXTENSION',
MODULE = 'MODULE'
} }
export const ResourceTypeMIMETypes = new Map<ResourceType, string>( export const ResourceTypeMIMETypes = new Map<ResourceType, string>(
@ -59,6 +61,15 @@ export const ResourceTypeTranslationMap = new Map<ResourceType, string>(
] ]
); );
export const ResourceSubTypeTranslationMap = new Map<ResourceSubType, string>(
[
[ResourceSubType.IMAGE, 'resource.sub-type.image'],
[ResourceSubType.SCADA_SYMBOL, 'resource.sub-type.scada-symbol'],
[ResourceSubType.EXTENSION, 'resource.sub-type.extension'],
[ResourceSubType.MODULE, 'resource.sub-type.module']
]
);
export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' | 'label'>, HasTenantId, ExportableEntity<TbResourceId> { export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' | 'label'>, HasTenantId, ExportableEntity<TbResourceId> {
tenantId?: TenantId; tenantId?: TenantId;
resourceKey?: string; resourceKey?: string;

11
ui-ngx/src/app/shared/models/widget.models.ts

@ -44,6 +44,7 @@ import { NULL_UUID } from '@shared/models/id/has-uuid';
import { HasTenantId, HasVersion } from '@shared/models/entity.models'; import { HasTenantId, HasVersion } from '@shared/models/entity.models';
import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models';
import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models';
import { TbFunction } from '@shared/models/js-function.models';
export enum widgetType { export enum widgetType {
timeseries = 'timeseries', timeseries = 'timeseries',
@ -151,7 +152,7 @@ export interface WidgetTypeDescriptor {
resources: Array<WidgetResource>; resources: Array<WidgetResource>;
templateHtml: string; templateHtml: string;
templateCss: string; templateCss: string;
controllerScript: string; controllerScript: TbFunction;
settingsSchema?: string | any; settingsSchema?: string | any;
dataKeySettingsSchema?: string | any; dataKeySettingsSchema?: string | any;
latestDataKeySettingsSchema?: string | any; latestDataKeySettingsSchema?: string | any;
@ -333,8 +334,8 @@ export interface KeyInfo {
comparisonResultType?: ComparisonResultType; comparisonResultType?: ComparisonResultType;
label?: string; label?: string;
color?: string; color?: string;
funcBody?: string; funcBody?: TbFunction;
postFuncBody?: string; postFuncBody?: TbFunction;
units?: string; units?: string;
decimals?: number; decimals?: number;
} }
@ -656,7 +657,7 @@ export interface WidgetMobileActionDescriptor extends WidgetMobileActionDescript
} }
export interface CustomActionDescriptor { export interface CustomActionDescriptor {
customFunction?: string; customFunction?: TbFunction;
customResources?: Array<WidgetResource>; customResources?: Array<WidgetResource>;
customHtml?: string; customHtml?: string;
customCss?: string; customCss?: string;
@ -693,7 +694,7 @@ export interface WidgetActionDescriptor extends WidgetAction {
icon: string; icon: string;
displayName?: string; displayName?: string;
useShowWidgetActionFunction?: boolean; useShowWidgetActionFunction?: boolean;
showWidgetActionFunction?: string; showWidgetActionFunction?: TbFunction;
columnIndex?: number; columnIndex?: number;
} }

6
ui-ngx/src/app/shared/shared.module.ts

@ -218,6 +218,8 @@ import { CountryAutocompleteComponent } from '@shared/components/country-autocom
import { CountryData } from '@shared/models/country.models'; import { CountryData } from '@shared/models/country.models';
import { SvgXmlComponent } from '@shared/components/svg-xml.component'; import { SvgXmlComponent } from '@shared/components/svg-xml.component';
import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component'; import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService; return markedOptionsService;
@ -333,6 +335,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
JsonObjectViewComponent, JsonObjectViewComponent,
JsonContentComponent, JsonContentComponent,
JsFuncComponent, JsFuncComponent,
JsFuncModulesComponent,
JsFuncModuleRowComponent,
CssComponent, CssComponent,
HtmlComponent, HtmlComponent,
SvgXmlComponent, SvgXmlComponent,
@ -538,6 +542,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
JsonObjectViewComponent, JsonObjectViewComponent,
JsonContentComponent, JsonContentComponent,
JsFuncComponent, JsFuncComponent,
JsFuncModulesComponent,
JsFuncModuleRowComponent,
CssComponent, CssComponent,
HtmlComponent, HtmlComponent,
SvgXmlComponent, SvgXmlComponent,

45
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3315,7 +3315,21 @@
"no-return-error": "Function must return value!", "no-return-error": "Function must return value!",
"return-type-mismatch": "Function must return value of '{{type}}' type!", "return-type-mismatch": "Function must return value of '{{type}}' type!",
"tidy": "Tidy", "tidy": "Tidy",
"mini": "Mini" "mini": "Mini",
"modules": "Modules",
"remove-module": "Remove module",
"no-modules": "No modules configured",
"add-module": "Add module",
"module-alias": "Alias",
"module-resource": "JS module resource",
"not-unique-module-aliases-error": "Modules aliases must be unique!",
"show-module-info": "Show module info",
"show-module-source-code": "Show module source code",
"module-members": "Module members",
"module-no-members": "Module has no exported members",
"module-load-error": "Module load error",
"source-code": "Source code",
"source-code-load-error": "Source code load error"
}, },
"key-val": { "key-val": {
"key": "Key", "key": "Key",
@ -4103,8 +4117,37 @@
"js-module": "JS module", "js-module": "JS module",
"lwm2m-model": "LWM2M model", "lwm2m-model": "LWM2M model",
"pkcs-12": "PKCS #12" "pkcs-12": "PKCS #12"
},
"resource-sub-type": "Sub-type",
"sub-type": {
"image": "image",
"scada-symbol": "Scada symbol",
"extension": "Extension",
"module": "Module"
} }
}, },
"javascript": {
"add": "Add JavaScript resource",
"delete": "Delete JavaScript resource",
"delete-javascript-resource-text": "Be careful, after the confirmation the JavaScript resource will become unrecoverable.",
"delete-javascript-resource-title": "Are you sure you want to delete the JavaScript resource '{{resourceTitle}}'?",
"delete-javascript-resources-action-title": "Delete JavaScript { count, plural, =1 {1 resource} other {# resources} }",
"delete-javascript-resources-text": "Please note that the selected JavaScript resources, even if they are used in JavaScript functions, will be deleted.",
"delete-javascript-resources-title": "Are you sure you want to delete JavaScript { count, plural, =1 {1 resource} other {# resources} }?",
"download": "Download JavaScript resource",
"upload-from-file": "Upload JavaScript from file",
"resource-file": "JavaScript resource file",
"drop-file": "Drop a JavaScript file or click to select a file to upload.",
"drop-resource-file-or": "Drag and drop a JavaScript file or",
"javascript-library": "JavaScript library",
"javascript-type": "JavaScript type",
"javascript-resource-details": "JavaScript resource details",
"search": "Search JavaScript resources",
"selected-javascript-resources": "{ count, plural, =1 {1 JavaScript resource} other {# JavaScript resources} } selected",
"no-javascript-resource-text": "No JavaScript resources found",
"all-types": "All",
"module-script": "Module script"
},
"rpc": { "rpc": {
"error": { "error": {
"target-device-is-not-set": "Target device is not set!", "target-device-is-not-set": "Target device is not set!",

9
ui-ngx/src/form.scss

@ -652,6 +652,15 @@
} }
} }
} }
.mat-mdc-form-field.tb-inline-field {
.mat-mdc-text-field-wrapper {
.mat-mdc-form-field-icon-prefix, .mat-mdc-form-field-icon-suffix,
.mat-datetimepicker-toggle {
line-height: normal;
}
}
}
} }
.tb-no-data-available { .tb-no-data-available {

Loading…
Cancel
Save