diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index f5671178ac..9390238139 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -192,4 +192,10 @@ $$ END IF; ALTER TABLE qr_code_settings DROP COLUMN IF EXISTS android_config, DROP COLUMN IF EXISTS ios_config; END; -$$; \ No newline at end of file +$$; + +-- 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 diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index c3666501b5..c7a59cfd83 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/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_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. "; diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index 3671cec189..0d4b988771 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/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.RequestParam; 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.TbResource; 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_ID_PARAM_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_TYPE; import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; @@ -229,6 +231,8 @@ public class TbResourceController extends BaseController { @RequestParam int page, @Parameter(description = RESOURCE_TYPE, schema = @Schema(allowableValues = {"LWM2M_MODEL", "JKS", "PKCS_12", "JS_MODULE"})) @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) @RequestParam(required = false) String textSearch, @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "title", "resourceType", "tenantId"})) @@ -241,8 +245,12 @@ public class TbResourceController extends BaseController { Set resourceTypes = new HashSet<>(); if (StringUtils.isNotEmpty(resourceType)) { resourceTypes.add(ResourceType.valueOf(resourceType)); + if (StringUtils.isNotEmpty(resourceSubType)) { + filter.resourceSubTypes(Set.of(ResourceSubType.valueOf(resourceSubType))); + } } else { Collections.addAll(resourceTypes, ResourceType.values()); + resourceTypes.remove(ResourceType.JS_MODULE); resourceTypes.remove(ResourceType.IMAGE); resourceTypes.remove(ResourceType.DASHBOARD); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java index 10a033f75d..17b9621d2b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceSubType.java +++ b/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 { IMAGE, - SCADA_SYMBOL + SCADA_SYMBOL, + EXTENSION, + MODULE } diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties index d72bb10b82..b688c3c40f 100644 --- a/dao/src/test/resources/nosql-test.properties +++ b/dao/src/test/resources/nosql-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=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.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index eb50e96e75..e3f4861aa9 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -14,7 +14,7 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=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.hikari.maximumPoolSize=16 diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index a1c88b3330..46cd2af7ab 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -19,14 +19,16 @@ import { DataEntry, DataSet, DataSetHolder, - DatasourceType, IndexedData, + DatasourceType, + IndexedData, widgetType } from '@shared/models/widget.models'; import { AggregationType, ComparisonDuration, createTimewindowForComparison, - getCurrentTime, IntervalMath, + getCurrentTime, + IntervalMath, SubscriptionTimewindow } from '@shared/models/time/time.models'; import { @@ -51,7 +53,8 @@ import { IndexedSubscriptionData, IntervalType, NOT_SUPPORTED, - SubscriptionData, SubscriptionDataEntry, + SubscriptionData, + SubscriptionDataEntry, TelemetrySubscriber } from '@shared/models/telemetry/telemetry.models'; 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 { NULL_UUID } from '@shared/models/id/has-uuid'; 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 { 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; declare type DataKeyFunction = (time: number, prevValue: any) => any; @@ -79,10 +89,10 @@ export interface SubscriptionDataKey { timeForComparison?: ComparisonDuration; comparisonCustomIntervalValue?: number; comparisonResultType?: ComparisonResultType; - funcBody: string; - func?: DataKeyFunction; - postFuncBody: string; - postFunc?: DataKeyPostFunction; + funcBody: TbFunction; + func?: CompiledTbFunction; + postFuncBody: TbFunction; + postFunc?: CompiledTbFunction; index?: number; listIndex?: number; key?: string; @@ -109,8 +119,8 @@ export class EntityDataSubscription { constructor(private listener: EntityDataListener, private telemetryService: TelemetryWebsocketService, - private utils: UtilsService) { - this.initializeSubscription(); + private utils: UtilsService, + private http: HttpClient) { } private entityDataSubscriptionOptions = this.listener.subscriptionOptions; @@ -193,19 +203,20 @@ export class EntityDataSubscription { return [[timestamp, value]]; } - private initializeSubscription() { + private async initializeSubscription() { for (let i = 0; i < this.entityDataSubscriptionOptions.dataKeys.length; i++) { const dataKey = deepClone(this.entityDataSubscriptionOptions.dataKeys[i]); this.dataKeysList.push(dataKey); dataKey.index = i; if (this.datasourceType === DatasourceType.function) { 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 { - if (dataKey.postFuncBody && !dataKey.postFunc) { - dataKey.postFunc = new Function('time', 'value', 'prevValue', 'timePrev', 'prevOrigValue', - dataKey.postFuncBody) as DataKeyPostFunction; + if (isNotEmptyTbFunction(dataKey.postFuncBody) && !dataKey.postFunc) { + try { + dataKey.postFunc = await firstValueFrom(compileTbFunction(this.http, dataKey.postFuncBody, 'time', 'value', 'prevValue', 'timePrev', 'prevOrigValue')); + } catch (e) {} } } let key: string; @@ -266,348 +277,352 @@ export class EntityDataSubscription { public subscribe(): Observable { this.entityDataResolveSubject = new ReplaySubject(1); - if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { - this.started = true; - this.dataResolved = true; - this.prepareSubscriptionTimewindow(); - } - if (this.datasourceType === DatasourceType.entity) { - const entityFields: Array = - this.dataKeysList.filter(dataKey => dataKey.type === DataKeyType.entityField).map( - dataKey => ({ type: EntityKeyType.ENTITY_FIELD, key: dataKey.name }) - ); - if (!entityFields.find(key => key.key === 'name')) { - entityFields.push({ - type: EntityKeyType.ENTITY_FIELD, - key: 'name' - }); - } - if (!entityFields.find(key => key.key === 'label')) { - entityFields.push({ - type: EntityKeyType.ENTITY_FIELD, - key: 'label' - }); - } - if (!entityFields.find(key => key.key === 'additionalInfo')) { - entityFields.push({ - type: EntityKeyType.ENTITY_FIELD, - key: 'additionalInfo' - }); - } + from(this.initializeSubscription()).pipe( + tap(() => { + if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { + this.started = true; + this.dataResolved = true; + this.prepareSubscriptionTimewindow(); + } + if (this.datasourceType === DatasourceType.entity) { + const entityFields: Array = + this.dataKeysList.filter(dataKey => dataKey.type === DataKeyType.entityField).map( + dataKey => ({ type: EntityKeyType.ENTITY_FIELD, key: dataKey.name }) + ); + if (!entityFields.find(key => key.key === 'name')) { + entityFields.push({ + type: EntityKeyType.ENTITY_FIELD, + key: 'name' + }); + } + if (!entityFields.find(key => key.key === 'label')) { + entityFields.push({ + type: EntityKeyType.ENTITY_FIELD, + key: 'label' + }); + } + if (!entityFields.find(key => key.key === 'additionalInfo')) { + entityFields.push({ + type: EntityKeyType.ENTITY_FIELD, + key: 'additionalInfo' + }); + } - this.attrFields = this.dataKeysList.filter(dataKey => dataKey.type === DataKeyType.attribute).map( - dataKey => ({ type: EntityKeyType.ATTRIBUTE, key: dataKey.name }) - ); + this.attrFields = this.dataKeysList.filter(dataKey => dataKey.type === DataKeyType.attribute).map( + dataKey => ({ type: EntityKeyType.ATTRIBUTE, key: dataKey.name }) + ); - this.tsFields = this.dataKeysList. - filter(dataKey => dataKey.type === DataKeyType.timeseries && + this.tsFields = this.dataKeysList. + filter(dataKey => dataKey.type === DataKeyType.timeseries && (!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) { - const latestTsFields = this.dataKeysList. - filter(dataKey => dataKey.type === DataKeyType.timeseries && dataKey.latest && - (!dataKey.aggregationType || dataKey.aggregationType === AggregationType.NONE)).map( - dataKey => ({ type: EntityKeyType.TIME_SERIES, key: dataKey.name }) - ); - this.latestValues = this.attrFields.concat(latestTsFields); - } else { - this.latestValues = this.attrFields.concat(this.tsFields); - } + if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { + const latestTsFields = this.dataKeysList. + filter(dataKey => dataKey.type === DataKeyType.timeseries && dataKey.latest && + (!dataKey.aggregationType || dataKey.aggregationType === AggregationType.NONE)).map( + dataKey => ({ type: EntityKeyType.TIME_SERIES, key: dataKey.name }) + ); + this.latestValues = this.attrFields.concat(latestTsFields); + } else { + this.latestValues = this.attrFields.concat(this.tsFields); + } - this.aggTsValues = this.dataKeysList. + this.aggTsValues = this.dataKeysList. filter(dataKey => dataKey.type === DataKeyType.timeseries && dataKey.aggregationType && dataKey.aggregationType !== AggregationType.NONE && !dataKey.comparisonEnabled).map( dataKey => ({ id: dataKey.index, key: dataKey.name, agg: dataKey.aggregationType }) ); - this.aggTsComparisonValues = this.dataKeysList. - filter(dataKey => dataKey.type === DataKeyType.timeseries && - dataKey.aggregationType && dataKey.aggregationType !== AggregationType.NONE && dataKey.comparisonEnabled).map( - dataKey => ({ id: dataKey.index, key: dataKey.name, agg: dataKey.aggregationType, - previousValueOnly: dataKey.comparisonResultType === ComparisonResultType.PREVIOUS_VALUE }) - ); + this.aggTsComparisonValues = this.dataKeysList. + filter(dataKey => dataKey.type === DataKeyType.timeseries && + dataKey.aggregationType && dataKey.aggregationType !== AggregationType.NONE && dataKey.comparisonEnabled).map( + dataKey => ({ id: dataKey.index, key: dataKey.name, agg: dataKey.aggregationType, + previousValueOnly: dataKey.comparisonResultType === ComparisonResultType.PREVIOUS_VALUE }) + ); - this.subscriber = new TelemetrySubscriber(this.telemetryService); - this.dataCommand = new EntityDataCmd(); + this.subscriber = new TelemetrySubscriber(this.telemetryService); + this.dataCommand = new EntityDataCmd(); - let keyFilters = this.entityDataSubscriptionOptions.keyFilters; - if (this.entityDataSubscriptionOptions.additionalKeyFilters) { - if (keyFilters) { - keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters); - } else { - keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters; - } - } + let keyFilters = this.entityDataSubscriptionOptions.keyFilters; + if (this.entityDataSubscriptionOptions.additionalKeyFilters) { + if (keyFilters) { + keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters); + } else { + keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters; + } + } - this.dataCommand.query = { - entityFilter: this.entityDataSubscriptionOptions.entityFilter, - pageLink: this.entityDataSubscriptionOptions.pageLink, - keyFilters, - entityFields, - latestValues: this.latestValues - }; + this.dataCommand.query = { + entityFilter: this.entityDataSubscriptionOptions.entityFilter, + pageLink: this.entityDataSubscriptionOptions.pageLink, + keyFilters, + entityFields, + latestValues: this.latestValues + }; - if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { - this.prepareSubscriptionCommands(this.dataCommand); - if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { - this.subscriber.setTsOffset(this.subsTw.tsOffset); - } else { - this.subscriber.setTsOffset(this.latestTsOffset); - } - } + if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { + this.prepareSubscriptionCommands(this.dataCommand); + if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { + this.subscriber.setTsOffset(this.subsTw.tsOffset); + } else { + this.subscriber.setTsOffset(this.latestTsOffset); + } + } - this.subscriber.subscriptionCommands.push(this.dataCommand); + this.subscriber.subscriptionCommands.push(this.dataCommand); - this.subscriber.entityData$.subscribe( - (entityDataUpdate) => { - if (entityDataUpdate.data) { - this.onPageData(entityDataUpdate.data); - if (this.prematureUpdates) { - for (const update of this.prematureUpdates) { - this.onDataUpdate(update); + this.subscriber.entityData$.subscribe( + (entityDataUpdate) => { + if (entityDataUpdate.data) { + this.onPageData(entityDataUpdate.data); + if (this.prematureUpdates) { + for (const update of this.prematureUpdates) { + 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.prematureUpdates = []; + ); + + this.subscriber.reconnect$.subscribe(() => { + 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 { - 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(() => { - 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; + 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 = { + 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 = { + data: [entityData], + hasNext: false, + totalElements: 1, + totalPages: 1 + }; + this.onPageData(pageData); + dataReceived = true; } 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; - targetCommand.aggTsCmd.timeWindow = this.subsTw.aggregation.timeWindow; - this.tsLatestDataAggregators.forEach((dataAggregator) => { - dataAggregator.reset(newSubsTw); - }); + ); + 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; } } - 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 { - 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 = { - 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 = { - 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.alarmCountCommand.query = { + entityFilter: this.entityDataSubscriptionOptions.entityFilter, + keyFilters + }; + if (this.entityDataSubscriptionOptions.alarmFilter) { + this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter}; } - } - ); - 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]; + this.subscriber.subscriptionCommands.push(this.alarmCountCommand); - let dataReceived = false; + const entityId: EntityId = { + id: NULL_UUID, + entityType: null + }; - this.subscriber.alarmCount$.subscribe( - (alarmCountUpdate) => { - if (!dataReceived) { - const entityData: EntityData = { - entityId, - latest: { - [EntityKeyType.ENTITY_FIELD]: { - name: { - ts: Date.now() + this.latestTsOffset, - value: DatasourceType.alarmCount - } - }, - [EntityKeyType.COUNT]: { - [countKey.name]: { - ts: Date.now() + this.latestTsOffset, - value: alarmCountUpdate.count + '' - } - } - }, - timeseries: {} - }; - const pageData: PageData = { - 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: alarmCountUpdate.count + '' - } - } - }, - timeseries: {} - }]; - this.onDataUpdate(update); - } + const countKey = this.dataKeysList[0]; + + let dataReceived = false; + + this.subscriber.alarmCount$.subscribe( + (alarmCountUpdate) => { + if (!dataReceived) { + const entityData: EntityData = { + entityId, + latest: { + [EntityKeyType.ENTITY_FIELD]: { + name: { + ts: Date.now() + this.latestTsOffset, + value: DatasourceType.alarmCount + } + }, + [EntityKeyType.COUNT]: { + [countKey.name]: { + ts: Date.now() + this.latestTsOffset, + value: alarmCountUpdate.count + '' + } + } + }, + timeseries: {} + }; + const pageData: PageData = { + 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: alarmCountUpdate.count + '' + } + } + }, + timeseries: {} + }]; + this.onDataUpdate(update); + } + } + ); + this.subscriber.subscribe(); } - ); - this.subscriber.subscribe(); - } + }) + ).subscribe(); if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { return of(null); } else { @@ -1104,7 +1119,7 @@ export class EntityDataSubscription { this.datasourceOrigData[dataIndex][datasourceKey].data.push([series[0], series[1], series[2]]); let value = EntityDataSubscription.convertValue(series[1]); 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]]; 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]]); let value = EntityDataSubscription.convertValue(series[1]); 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]]; data.push([series[0], series[1], series[2]]); @@ -1196,7 +1211,7 @@ export class EntityDataSubscription { prevSeries = [0, 0]; } 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]; data.push(series); prevSeries = series; @@ -1217,7 +1232,7 @@ export class EntityDataSubscription { prevSeries = [0, 0]; } 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]; this.datasourceData[0][datasourceKey].data = [series]; this.listener.dataUpdated(this.datasourceData[0][datasourceKey], diff --git a/ui-ngx/src/app/core/api/entity-data.service.ts b/ui-ngx/src/app/core/api/entity-data.service.ts index 5ebad5e686..d631b0bbe0 100644 --- a/ui-ngx/src/app/core/api/entity-data.service.ts +++ b/ui-ngx/src/app/core/api/entity-data.service.ts @@ -28,6 +28,7 @@ import { SubscriptionDataKey } from '@core/api/entity-data-subscription'; import { Observable, of } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; export interface EntityDataListener { subscriptionType: widgetType; @@ -62,7 +63,8 @@ export interface EntityDataLoadResult { export class EntityDataService { constructor(private telemetryService: TelemetryWebsocketService, - private utils: UtilsService) {} + private utils: UtilsService, + private http: HttpClient) {} private static isUnresolvedDatasource(datasource: Datasource, pageLink: EntityDataPageLink): boolean { if (datasource.type === DatasourceType.entity) { @@ -103,7 +105,7 @@ export class EntityDataService { if (EntityDataService.isUnresolvedDatasource(datasource, datasource.pageLink)) { 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(); } @@ -137,7 +139,7 @@ export class EntityDataService { listener.configDatasourceIndex, listener.subscriptionOptions.pageLink); 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) { listener.subscriptionOptions.subscriptionTimewindow = deepClone(listener.subscriptionTimewindow); } diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 78f025e59d..f8e0920119 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/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 { forkJoin, Observable, of } from 'rxjs'; 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 { isNotEmptyStr } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; @@ -36,11 +36,14 @@ export class ResourceService { } - public getResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable> { + public getResources(pageLink: PageLink, resourceType?: ResourceType, resourceSubType?: ResourceSubType, config?: RequestConfig): Observable> { let url = `/api/resource${pageLink.toQuery()}`; if (isNotEmptyStr(resourceType)) { url += `&resourceType=${resourceType}`; } + if (isNotEmptyStr(resourceSubType)) { + url += `&resourceSubType=${resourceSubType}`; + } return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index cf08fe2a56..16a87770ac 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -59,6 +59,7 @@ export enum MenuId { images = 'images', scada_symbols = 'scada_symbols', resources_library = 'resources_library', + javascript_library = 'javascript_library', notifications_center = 'notifications_center', notification_inbox = 'notification_inbox', notification_sent = 'notification_sent', @@ -209,6 +210,16 @@ export const menuSectionMap = new Map([ 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, { @@ -707,6 +718,7 @@ const defaultUserMenuMap = new Map([ }, {id: MenuId.images}, {id: MenuId.scada_symbols}, + {id: MenuId.javascript_library}, {id: MenuId.resources_library} ] }, @@ -803,6 +815,7 @@ const defaultUserMenuMap = new Map([ }, {id: MenuId.images}, {id: MenuId.scada_symbols}, + {id: MenuId.javascript_library}, {id: MenuId.resources_library} ] }, diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index f2f2ba39e8..79fde27014 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -14,12 +14,9 @@ /// limitations under the License. /// -// eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// - import { Inject, Injectable, NgZone, Renderer2 } from '@angular/core'; 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 { base64toObj, base64toString, @@ -53,6 +50,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { DatePipe, DOCUMENT } from '@angular/common'; import { entityTypeTranslations } from '@shared/models/entity-type.models'; import cssjs from '@core/css/css'; +import { isNotEmptyTbFunction } from '@shared/models/js-function.models'; const i18nRegExp = new RegExp(`{${i18nPrefix}:[^{}]+}`, 'g'); @@ -214,42 +212,7 @@ export class UtilsService { } public 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 = /(.*):(\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; + return parseException(exception, lineOffset); } public customTranslation(translationValue: string, defaultValue: string): string { @@ -326,7 +289,7 @@ export class UtilsService { } else if (index > -1) { dataKey.color = this.getMaterialColor(index); } - if (keyInfo.postFuncBody && keyInfo.postFuncBody.length) { + if (isNotEmptyTbFunction(keyInfo.postFuncBody)) { dataKey.usePostProcessing = true; dataKey.postFuncBody = keyInfo.postFuncBody; } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 43030a19f6..34735732d0 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -15,16 +15,22 @@ /// import _ from 'lodash'; -import { from, Observable, Subject } from 'rxjs'; -import { finalize, share } from 'rxjs/operators'; +import { from, Observable, of, ReplaySubject, Subject } from 'rxjs'; +import { catchError, finalize, share } from 'rxjs/operators'; import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; import { EntityId } from '@shared/models/id/entity-id'; import { NULL_UUID } from '@shared/models/id/has-uuid'; 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 { serverErrorCodesTranslations } from '@shared/models/constants'; import { SubscriptionEntityInfo } from '@core/api/widget-api.models'; +import { + CompiledTbFunction, + compileTbFunction, GenericFunction, + isNotEmptyTbFunction, + TbFunction +} from '@shared/models/js-function.models'; const varsRegex = /\${([^}]*)}/g; @@ -673,6 +679,39 @@ export function parseFunction(source: any, params: string[] = ['def']): (...args return res; } +export function parseTbFunction(http: HttpClient, source: TbFunction, params: string[] = ['def']): Observable> { + if (isNotEmptyTbFunction(source)) { + return compileTbFunction(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(func: CompiledTbFunction, 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 = []) { let res = null; if (func && typeof (func) === 'function') { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html index d06493594e..621aebeae9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html @@ -107,6 +107,7 @@ { + if (this.dataKeyFormGroup.valid) { + const dataKey: DataKey = this.dataKeyFormGroup.get('dataKey').value; + this.dialogRef.close(dataKey); + } + }); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html index 1930016656..29c20f957c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.html @@ -72,6 +72,7 @@ [functionArgs]="['time', 'prevValue']" [globalVariables]="functionScopeVariables" [validationArgs]="[[1, 1],[1, '1']]" + withModules resultType="any" helpId="widget/config/datakey_generation_fn" formControlName="funcBody"> @@ -174,6 +175,7 @@ [functionArgs]="['time', 'value', 'prevValue', 'timePrev', 'prevOrigValue']" [globalVariables]="functionScopeVariables" [validationArgs]="[[1, 1, 1, 1, 1],[1, '1', '1', 1, '1']]" + withModules resultType="any" helpId="widget/config/datakey_postprocess_fn" formControlName="postFuncBody"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts index 4db2aaa8bf..32e8727225 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-key-config.component.ts +++ b/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 { WidgetComponentService } from '@home/components/widget/widget-component.service'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; +import { isNotEmptyTbFunction, TbFunction } from '@shared/models/js-function.models'; @Component({ 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) => { - const postFuncBody: string = this.dataKeyFormGroup.get('postFuncBody').value; - if (usePostProcessing && (!postFuncBody || !postFuncBody.length)) { + const postFuncBody: TbFunction = this.dataKeyFormGroup.get('postFuncBody').value; + if (usePostProcessing && !isNotEmptyTbFunction(postFuncBody)) { this.dataKeyFormGroup.get('postFuncBody').patchValue('return value;'); - } else if (!usePostProcessing && postFuncBody && postFuncBody.length) { + } else if (!usePostProcessing && isNotEmptyTbFunction(postFuncBody)) { this.dataKeyFormGroup.get('postFuncBody').patchValue(null); } }); @@ -317,7 +318,7 @@ export class DataKeyConfigComponent extends PageComponent implements OnInit, Con writeValue(value: DataKey): void { this.modelValue = value; - if (this.modelValue.postFuncBody && this.modelValue.postFuncBody.length) { + if (isNotEmptyTbFunction(this.modelValue.postFuncBody)) { this.modelValue.usePostProcessing = true; } 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); } - public validateOnSubmit() { + public validateOnSubmit(): Observable { if (this.modelValue.type === DataKeyType.function && this.funcBodyEdit) { - this.funcBodyEdit.validateOnSubmit(); + return this.funcBodyEdit.validateOnSubmit(); } else if ((this.modelValue.type === DataKeyType.timeseries || this.modelValue.type === DataKeyType.attribute) && this.dataKeyFormGroup.get('usePostProcessing').value && this.postFuncBodyEdit) { - this.postFuncBodyEdit.validateOnSubmit(); + return this.postFuncBodyEdit.validateOnSubmit(); + } else { + return of(null); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts index f4d9cf8d58..38d704d51b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts +++ b/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 { BehaviorSubject, forkJoin, Observable, Observer, of, Subscription, throwError } from 'rxjs'; 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 { DataToValueSettings, @@ -45,6 +44,7 @@ import { ValueType } from '@shared/models/constants'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { EntityId } from '@shared/models/id/entity-id'; import { isDefinedAndNotNull } from '@core/utils'; +import { parseError } from '@shared/models/error.models'; @Directive() // eslint-disable-next-line @angular-eslint/directive-class-suffix @@ -110,7 +110,7 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A } }, error: (err: any) => { - const message = parseError(this.ctx, err); + const message = parseError(err); this.onError(message); if (valueObserver?.error) { valueObserver.error(err); @@ -152,7 +152,7 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A if (setValueObserver?.error) { setValueObserver.error(err); } - const message = parseError(this.ctx, err); + const message = parseError(err); this.onError(message); } }); @@ -214,7 +214,7 @@ export abstract class ValueAction { protected settings: ValueActionSettings) {} 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', {actionLabel: this.settings.actionLabel}); if (reason) { @@ -709,15 +709,12 @@ export class TimeSeriesValueSetter extends TelemetryValueSetter { } -const parseError = (ctx: WidgetContext, err: any): string => - ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; - const handleRpcError = (ctx: WidgetContext, err: any): Error => { let reason: string; if (ctx.defaultSubscription.rpcErrorText) { reason = ctx.defaultSubscription.rpcErrorText; } else { - reason = parseError(ctx, err); + reason = parseError(err); } return new Error(reason); }; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html index 7db8a28556..1df8050167 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html +++ b/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)"> - + @@ -82,10 +82,10 @@ {{ column.title }} - + - +
@@ -171,7 +171,7 @@ [class.invisible]="alarmsDatasource.dataLoading" [class.tb-pointer]="hasRowAction" *matRowDef="let alarm; columns: displayedColumns; let row = index" - [style]="rowStyle(alarm, row)" + [style]="rowStyle(alarm, row) | async" (click)="onRowClick($event, alarm)"> } = {}; private columnWidth: {[key: string]: string} = {}; private columnDefaultVisibility: {[key: string]: boolean} = {}; private columnSelectionAvailability: {[key: string]: boolean} = {}; private columnsWithCellClick: Array = []; - private rowStylesInfo: RowStyleInfo; + private rowStylesInfo: Observable; private widgetTimewindowChanged$: Subscription; @@ -389,7 +389,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, } 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; if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { @@ -438,10 +438,14 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, keySettings.columnWidth = '120px'; } } - this.stylesInfo[dataKey.def] = getCellStyleInfo(keySettings, 'value, alarm, ctx'); - this.contentsInfo[dataKey.def] = getCellContentInfo(keySettings, 'value, alarm, ctx'); - this.contentsInfo[dataKey.def].units = dataKey.units; - this.contentsInfo[dataKey.def].decimals = dataKey.decimals; + this.stylesInfo[dataKey.def] = getCellStyleInfo(this.ctx, keySettings, 'value, alarm, ctx'); + const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, alarm, ctx'); + const contentInfo: CellContentInfo = { + contentFunction: contentFunctionInfo, + units: dataKey.units, + decimals: dataKey.decimals + }; + this.contentsInfo[dataKey.def] = contentInfo; this.columnWidth[dataKey.def] = getColumnWidth(keySettings); this.columnDefaultVisibility[dataKey.def] = getColumnDefaultVisibility(keySettings, this.ctx); this.columnSelectionAvailability[dataKey.def] = getColumnSelectionAvailability(keySettings); @@ -719,100 +723,135 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, return widthStyle(columnWidth); } - public rowStyle(alarm: AlarmDataInfo, row: number): any { + public rowStyle(alarm: AlarmDataInfo, row: number): Observable { + let style$: Observable; let res = this.rowStyleCache[row]; if (!res) { - res = {}; - if (alarm && this.rowStylesInfo.useRowStyleFunction && this.rowStylesInfo.rowStyleFunction) { - try { - res = this.rowStylesInfo.rowStyleFunction(alarm, this.ctx); - if (!isObject(res)) { - throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); - } - if (Array.isArray(res)) { - throw new TypeError(`Array instead of style object`); + style$ = this.rowStylesInfo.pipe( + map(styleInfo => { + if (styleInfo.useRowStyleFunction && styleInfo.rowStyleFunction) { + const style = styleInfo.rowStyleFunction.execute(alarm, this.ctx); + if (!isObject(style)) { + 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 {}; } - } catch (e) { - res = {}; + }), + catchError(e => { console.warn(`Row style function in widget '${this.ctx.widgetTitle}' ` + `returns '${e}'. Please check your row style function.`); - } - } - this.rowStyleCache[row] = res; + return of({}); + }) + ); + 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 { + let style$: Observable; const col = this.columns.indexOf(key); const index = row * this.columns.length + col; let res = this.cellStyleCache[index]; if (!res) { - res = {}; if (alarm && key) { - const styleInfo = this.stylesInfo[key.def]; - const value = getAlarmValue(alarm, key); - if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { - try { - res = styleInfo.cellStyleFunction(value, alarm, this.ctx); - if (!isObject(res)) { - throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); + style$ = this.stylesInfo[key.def].pipe( + map(styleInfo => { + const value = getAlarmValue(alarm, key); + if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { + const style = styleInfo.cellStyleFunction.execute(value, alarm, this.ctx); + if (!isObject(style)) { + 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`); - } - } catch (e) { - res = {}; + }), + catchError(e => { console.warn(`Cell style function for data key '${key.label}' in widget '${this.ctx.widgetTitle}' ` + `returns '${e}'. Please check your cell style function.`); - } - } else { - res = this.defaultStyle(key, value); - } + return of({}); + }) + ); + } else { + style$ = of({}); } - this.cellStyleCache[index] = res; - } - if (!res.width) { - const columnWidth = this.columnWidth[key.def]; - res = Object.assign(res, widthStyle(columnWidth)); + 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); } - return res; + return style$; } - public cellContent(alarm: AlarmDataInfo, key: EntityColumn, row: number): SafeHtml { + public cellContent(alarm: AlarmDataInfo, key: EntityColumn, row: number): Observable { + let content$: Observable; const col = this.columns.indexOf(key); const index = row * this.columns.length + col; let res = this.cellContentCache[index]; if (isUndefined(res)) { - res = ''; - if (alarm && key) { - const contentInfo = this.contentsInfo[key.def]; - const value = getAlarmValue(alarm, key); - let content = ''; - if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { - try { - content = contentInfo.cellContentFunction(value, alarm, this.ctx); - } catch (e) { - content = '' + value; - } - } else { - content = this.defaultContent(key, contentInfo, value); - } - - if (isDefined(content)) { - content = this.utils.customTranslation(content, content); - switch (typeof content) { - case 'string': - res = this.domSanitizer.bypassSecurityTrustHtml(content); - break; - default: - res = content; + const contentInfo = this.contentsInfo[key.def]; + content$ = contentInfo.contentFunction.pipe( + map((contentFunction) => { + let content: any = ''; + if (alarm && key) { + const contentInfo = this.contentsInfo[key.def]; + const value = getAlarmValue(alarm, key); + if (contentFunction.useCellContentFunction && contentFunction.cellContentFunction) { + try { + content = contentFunction.cellContentFunction.execute(value, alarm, this.ctx); + } catch (e) { + content = '' + value; + } + } else { + content = this.defaultContent(key, contentInfo, value); + } + if (isDefined(content)) { + content = this.utils.customTranslation(content, content); + switch (typeof content) { + case 'string': + content = this.domSanitizer.bypassSecurityTrustHtml(content); + break; + } + } } - } - } - this.cellContentCache[index] = res; + return content; + }) + ); + 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) { @@ -1196,20 +1235,32 @@ class AlarmsDatasource implements DataSource { private reserveSpaceForHiddenAction = true; private cellButtonActions: TableCellButtonActionDescriptor[]; - private readonly usedShowCellActionFunction: boolean; + private usedShowCellActionFunction: boolean; + private inited = false; constructor(private subscription: IWidgetSubscription, private dataKeys: Array, private ngZone: NgZone, private widgetContext: WidgetContext, - actionCellDescriptors: AlarmWidgetActionDescriptor[]) { - this.cellButtonActions = actionCellDescriptors.concat(getTableCellButtonActions(widgetContext)); - this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction); + private actionCellDescriptors: AlarmWidgetActionDescriptor[]) { if (this.widgetContext.settings.reserveSpaceForHiddenAction) { this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction); } } + private init(): Observable { + 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> { return this.alarmsSubject.asObservable(); } @@ -1237,47 +1288,49 @@ class AlarmsDatasource implements DataSource { } updateAlarms() { - const subscriptionAlarms = this.subscription.alarms; - let alarms = new Array(); - let maxCellButtonAction = 0; - let isEmptySelection = false; - const dynamicWidthCellButtonActions = this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction; - subscriptionAlarms.data.forEach((alarmData) => { - const alarm = this.alarmDataToInfo(alarmData); - alarms.push(alarm); - if (dynamicWidthCellButtonActions && alarm.actionCellButtons.length > maxCellButtonAction) { - maxCellButtonAction = alarm.actionCellButtons.length; + this.init().subscribe(() => { + const subscriptionAlarms = this.subscription.alarms; + let alarms = new Array(); + let maxCellButtonAction = 0; + let isEmptySelection = false; + const dynamicWidthCellButtonActions = this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction; + subscriptionAlarms.data.forEach((alarmData) => { + const alarm = this.alarmDataToInfo(alarmData); + alarms.push(alarm); + if (dynamicWidthCellButtonActions && alarm.actionCellButtons.length > maxCellButtonAction) { + maxCellButtonAction = alarm.actionCellButtons.length; + } + }); + if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && alarms.length) { + maxCellButtonAction = alarms[0].actionCellButtons.length; } - }); - if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && alarms.length) { - maxCellButtonAction = alarms[0].actionCellButtons.length; - } - 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.appliedSortOrderLabel && this.appliedSortOrderLabel.length) { + const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC; + alarms = alarms.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc)); } - } - const alarmsPageData: PageData = { - data: alarms, - totalPages: subscriptionAlarms.totalPages, - totalElements: subscriptionAlarms.totalElements, - hasNext: subscriptionAlarms.hasNext - }; - this.ngZone.run(() => { - if (isEmptySelection) { - this.onSelectionModeChanged(false); + 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; + } } - this.alarmsSubject.next(alarms); - this.pageDataSubject.next(alarmsPageData); - this.countCellButtonAction = maxCellButtonAction; - this.dataLoading = false; + const alarmsPageData: PageData = { + data: alarms, + totalPages: subscriptionAlarms.totalPages, + 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; + }); }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html index 1a72f6ecdb..73b4f574e3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html @@ -43,8 +43,8 @@ {{ column.title }} @@ -59,7 +59,7 @@
- +
@@ -99,7 +99,7 @@ [class.invisible]="entityDatasource.dataLoading" [class.tb-pointer]="hasRowAction" *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)"> } = {}; private columnWidth: {[key: string]: string} = {}; private columnDefaultVisibility: {[key: string]: boolean} = {}; private columnSelectionAvailability: {[key: string]: boolean} = {}; private columnsWithCellClick: Array = []; - private rowStylesInfo: RowStyleInfo; + private rowStylesInfo: Observable; private searchAction: WidgetAction = { 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.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; if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { @@ -361,11 +362,13 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni } as EntityColumn ); this.contentsInfo.entityName = { - useCellContentFunction: false + contentFunction: of({ + useCellContentFunction: false + }) }; - this.stylesInfo.entityName = { + this.stylesInfo.entityName = of({ useCellStyleFunction: false - }; + }); this.columnWidth.entityName = '0px'; this.columnDefaultVisibility.entityName = true; this.columnSelectionAvailability.entityName = true; @@ -385,11 +388,13 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni } as EntityColumn ); this.contentsInfo.entityLabel = { - useCellContentFunction: false + contentFunction: of({ + useCellContentFunction: false + }) }; - this.stylesInfo.entityLabel = { + this.stylesInfo.entityLabel = of({ useCellStyleFunction: false - }; + }); this.columnWidth.entityLabel = '0px'; this.columnDefaultVisibility.entityLabel = true; this.columnSelectionAvailability.entityLabel = true; @@ -409,13 +414,18 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni } as EntityColumn ); this.contentsInfo.entityType = { - useCellContentFunction: true, - cellContentFunction: (entityType: EntityType) => - entityType ? this.translate.instant(entityTypeTranslations.get(entityType).type) : '' + contentFunction: of({ + useCellContentFunction: true, + cellContentFunction: new CompiledTbFunction( + (entityType: EntityType) => + entityType ? this.translate.instant(entityTypeTranslations.get(entityType).type) : '', + [] + ) + }) }; - this.stylesInfo.entityType = { + this.stylesInfo.entityType = of({ useCellStyleFunction: false - }; + }); this.columnWidth.entityType = '0px'; this.columnDefaultVisibility.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.contentsInfo[dataKey.def] = getCellContentInfo(keySettings, 'value, entity, ctx'); + this.stylesInfo[dataKey.def] = getCellStyleInfo(this.ctx, 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].decimals = dataKey.decimals; this.columnWidth[dataKey.def] = getColumnWidth(keySettings); @@ -595,98 +611,135 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni return widthStyle(columnWidth); } - public rowStyle(entity: EntityData, row: number): any { + public rowStyle(entity: EntityData, row: number): Observable { + let style$: Observable; let res = this.rowStyleCache[row]; if (!res) { - res = {}; - if (entity && this.rowStylesInfo.useRowStyleFunction && this.rowStylesInfo.rowStyleFunction) { - try { - res = this.rowStylesInfo.rowStyleFunction(entity, this.ctx); - if (!isObject(res)) { - throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); - } - if (Array.isArray(res)) { - throw new TypeError(`Array instead of style object`); + style$ = this.rowStylesInfo.pipe( + map(styleInfo => { + if (styleInfo.useRowStyleFunction && styleInfo.rowStyleFunction) { + const style = styleInfo.rowStyleFunction.execute(entity, this.ctx); + if (!isObject(style)) { + 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 {}; } - } catch (e) { - res = {}; + }), + catchError(e => { console.warn(`Row style function in widget '${this.ctx.widgetTitle}' ` + `returns '${e}'. Please check your row style function.`); - } - } - this.rowStyleCache[row] = res; + return of({}); + }) + ); + 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 { + let style$: Observable; const col = this.columns.indexOf(key); const index = row * this.columns.length + col; let res = this.cellStyleCache[index]; if (!res) { - res = {}; if (entity && key) { - const styleInfo = this.stylesInfo[key.def]; - const value = getEntityValue(entity, key); - if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { - try { - res = styleInfo.cellStyleFunction(value, entity, this.ctx); - if (!isObject(res)) { - throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); + style$ = this.stylesInfo[key.def].pipe( + map((styleInfo) => { + const value = getEntityValue(entity, key); + if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { + const style = styleInfo.cellStyleFunction.execute(value, entity, this.ctx); + if (!isObject(style)) { + 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`); - } - } catch (e) { - res = {}; + }), + catchError(e => { console.warn(`Cell style function for data key '${key.label}' in widget '${this.ctx.widgetTitle}' ` + `returns '${e}'. Please check your cell style function.`); - } - } - this.cellStyleCache[index] = res; + return of({}); + }) + ); + } 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) { - const columnWidth = this.columnWidth[key.def]; - res = Object.assign(res, widthStyle(columnWidth)); - } - return res; + return style$; } - public cellContent(entity: EntityData, key: EntityColumn, row: number): SafeHtml { + public cellContent(entity: EntityData, key: EntityColumn, row: number): Observable { + let content$: Observable; const col = this.columns.indexOf(key); const index = row * this.columns.length + col; let res = this.cellContentCache[index]; if (isUndefined(res)) { - res = ''; - if (entity && key) { - const contentInfo = this.contentsInfo[key.def]; - const value = getEntityValue(entity, key); - let content: string; - if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { - try { - content = contentInfo.cellContentFunction(value, entity, this.ctx); - } catch (e) { - content = '' + value; - } - } else { - content = this.defaultContent(key, contentInfo, value); - } - - if (isDefined(content)) { - content = this.utils.customTranslation(content, content); - switch (typeof content) { - case 'string': - res = this.domSanitizer.bypassSecurityTrustHtml(content); - break; - default: - res = content; + const contentInfo = this.contentsInfo[key.def]; + content$ = contentInfo.contentFunction.pipe( + map((contentFunction) => { + let content: any = ''; + if (entity && key) { + const contentInfo = this.contentsInfo[key.def]; + const value = getEntityValue(entity, key); + if (contentFunction.useCellContentFunction && contentFunction.cellContentFunction) { + try { + content = contentFunction.cellContentFunction.execute(value, entity, this.ctx); + } catch (e) { + content = '' + value; + } + } else { + content = this.defaultContent(key, contentInfo, value); + } + if (isDefined(content)) { + content = this.utils.customTranslation(content, content); + switch (typeof content) { + case 'string': + content = this.domSanitizer.bypassSecurityTrustHtml(content); + break; + } + } } - } - } - this.cellContentCache[index] = res; + return content; + }) + ); + 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 { @@ -789,7 +842,8 @@ class EntityDatasource implements DataSource { private reserveSpaceForHiddenAction = true; private cellButtonActions: TableCellButtonActionDescriptor[]; - private readonly usedShowCellActionFunction: boolean; + private usedShowCellActionFunction: boolean; + private inited = false; constructor( private translate: TranslateService, @@ -798,13 +852,24 @@ class EntityDatasource implements DataSource { private ngZone: NgZone, private widgetContext: WidgetContext ) { - this.cellButtonActions = getTableCellButtonActions(widgetContext); - this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction); if (this.widgetContext.settings.reserveSpaceForHiddenAction) { this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction); } } + private init(): Observable { + 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> { return this.entitiesSubject.asObservable(); } @@ -828,36 +893,38 @@ class EntityDatasource implements DataSource { } dataUpdated() { - const datasourcesPageData = this.subscription.datasourcePages[0]; - const dataPageData = this.subscription.dataPages[0]; - let entities = new Array(); - let maxCellButtonAction = 0; - const dynamicWidthCellButtonActions = this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction; - datasourcesPageData.data.forEach((datasource, index) => { - const entity = this.datasourceToEntityData(datasource, dataPageData.data[index]); - entities.push(entity); - if (dynamicWidthCellButtonActions && entity.actionCellButtons.length > maxCellButtonAction) { - maxCellButtonAction = entity.actionCellButtons.length; + this.init().subscribe(() => { + const datasourcesPageData = this.subscription.datasourcePages[0]; + const dataPageData = this.subscription.dataPages[0]; + let entities = new Array(); + let maxCellButtonAction = 0; + const dynamicWidthCellButtonActions = this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction; + datasourcesPageData.data.forEach((datasource, index) => { + const entity = this.datasourceToEntityData(datasource, dataPageData.data[index]); + entities.push(entity); + 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 (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) { - maxCellButtonAction = entities[0].actionCellButtons.length; - } - const entitiesPageData: PageData = { - data: entities, - totalPages: datasourcesPageData.totalPages, - totalElements: datasourcesPageData.totalElements, - hasNext: datasourcesPageData.hasNext - }; - this.ngZone.run(() => { - this.entitiesSubject.next(entities); - this.pageDataSubject.next(entitiesPageData); - this.countCellButtonAction = maxCellButtonAction; - this.dataLoading = false; + if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && entities.length) { + maxCellButtonAction = entities[0].actionCellButtons.length; + } + const entitiesPageData: PageData = { + data: entities, + totalPages: datasourcesPageData.totalPages, + totalElements: datasourcesPageData.totalElements, + hasNext: datasourcesPageData.hasNext + }; + this.ngZone.run(() => { + this.entitiesSubject.next(entities); + this.pageDataSubject.next(entitiesPageData); + this.countCellButtonAction = maxCellButtonAction; + this.dataLoading = false; + }); }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts index 2a61d008d4..08417f63f7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts @@ -28,18 +28,21 @@ import { hashCode, isDefinedAndNotNull, isNotEmptyStr, - parseFunction, - safeExecute + parseTbFunction, + safeExecuteTbFunction } from '@core/utils'; import cssjs from '@core/css/css'; import { UtilsService } from '@core/services/utils.service'; import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; 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 { markdownTextPattern: string; useMarkdownTextFunction: boolean; - markdownTextFunction: string; + markdownTextFunction: TbFunction; applyDefaultMarkdownStyle: boolean; markdownCss: string; } @@ -53,7 +56,7 @@ type MarkdownTextFunction = (data: FormattedData[], ctx: WidgetContext) => strin export class MarkdownWidgetComponent extends PageComponent implements OnInit { settings: MarkdownWidgetSettings; - markdownTextFunction: MarkdownTextFunction; + markdownTextFunction: Observable>; markdownClass: string; @@ -78,7 +81,7 @@ export class MarkdownWidgetComponent extends PageComponent implements OnInit { this.ctx.$scope.markdownWidget = this; this.settings = this.ctx.settings; 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; if (isNotEmptyStr(cssString)) { const cssParser = new cssjs(); @@ -126,8 +129,19 @@ export class MarkdownWidgetComponent extends PageComponent implements OnInit { initialData = []; } const data = formattedDataFormDatasourceData(initialData); + 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); markdownText = createLabelFromPattern(markdownText, allData); if (this.markdownText !== markdownText) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts index bec9ea32aa..a2e9cd3579 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts @@ -27,14 +27,18 @@ import { formattedDataFormDatasourceData, isNumber, isObject, - parseFunction, - safeExecute, unwrapModule + parseTbFunction, + safeExecuteTbFunction, + unwrapModule } from '@core/utils'; +import { CompiledTbFunction, TbFunction } from '@shared/models/js-function.models'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; interface QrCodeWidgetSettings { qrCodeTextPattern: string; useQrCodeTextFunction: boolean; - qrCodeTextFunction: string; + qrCodeTextFunction: TbFunction; } type QrCodeTextFunction = (data: FormattedData[]) => string; @@ -47,7 +51,7 @@ type QrCodeTextFunction = (data: FormattedData[]) => string; export class QrCodeWidgetComponent extends PageComponent implements OnInit, AfterViewInit { settings: QrCodeWidgetSettings; - qrCodeTextFunction: QrCodeTextFunction; + qrCodeTextFunction: Observable>; @Input() ctx: WidgetContext; @@ -68,7 +72,7 @@ export class QrCodeWidgetComponent extends PageComponent implements OnInit, Afte ngOnInit(): void { this.ctx.$scope.qrCodeWidget = this; 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 { @@ -81,7 +85,6 @@ export class QrCodeWidgetComponent extends PageComponent implements OnInit, Afte public onDataUpdated() { let initialData: DatasourceData[]; - let qrCodeText: string; if (this.ctx.data?.length) { initialData = this.ctx.data; } else if (this.ctx.datasources?.length) { @@ -100,13 +103,19 @@ export class QrCodeWidgetComponent extends PageComponent implements OnInit, Afte } const data = formattedDataFormDatasourceData(initialData); const pattern = this.settings.useQrCodeTextFunction ? - safeExecute(this.qrCodeTextFunction, [data]) : this.settings.qrCodeTextPattern; - const allData: FormattedData = flatDataWithoutOverride(data); - qrCodeText = createLabelFromPattern(pattern, allData); - this.updateQrCodeText(qrCodeText); + this.qrCodeTextFunction.pipe(map(qrCodeTextFunction => safeExecuteTbFunction(qrCodeTextFunction, [data]))) : this.settings.qrCodeTextPattern; + if (typeof pattern === 'string') { + this.updateQrCodeText(pattern, data); + } 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) { this.qrCodeText = newQrCodeText; if (!(isObject(newQrCodeText) || isNumber(newQrCodeText))) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.html index 5b5dc4fa49..c739f51440 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.html @@ -75,6 +75,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html index 369b675977..be2c437def 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html @@ -75,6 +75,7 @@ ; } export interface EntityData { @@ -88,9 +96,13 @@ export interface DisplayColumn { export type CellContentFunction = (...args: any[]) => string; -export interface CellContentInfo { +export interface CellContentFunctionInfo { useCellContentFunction: boolean; - cellContentFunction?: CellContentFunction; + cellContentFunction?: CompiledTbFunction; +} + +export interface CellContentInfo { + contentFunction: Observable; units?: string; decimals?: number; } @@ -99,14 +111,14 @@ export type CellStyleFunction = (...args: any[]) => any; export interface CellStyleInfo { useCellStyleFunction: boolean; - cellStyleFunction?: CellStyleFunction; + cellStyleFunction?: CompiledTbFunction; } export type RowStyleFunction = (...args: any[]) => any; export interface RowStyleInfo { useRowStyleFunction: boolean; - rowStyleFunction?: RowStyleFunction; + rowStyleFunction?: CompiledTbFunction; } @@ -229,67 +241,113 @@ export function getAlarmValue(alarm: AlarmDataInfo, key: EntityColumn) { } } -export function getRowStyleInfo(settings: TableWidgetSettings, ...args: string[]): RowStyleInfo { - let rowStyleFunction: RowStyleFunction = null; - let useRowStyleFunction = false; - - if (settings.useRowStyleFunction === true) { - if (isDefined(settings.rowStyleFunction) && settings.rowStyleFunction.length > 0) { - try { - rowStyleFunction = new Function(...args, settings.rowStyleFunction) as RowStyleFunction; - useRowStyleFunction = true; - } catch (e) { - rowStyleFunction = null; - useRowStyleFunction = false; - } - } +export function getRowStyleInfo(widgetContext: WidgetContext, settings: TableWidgetSettings, ...args: string[]): Observable { + let rowStyleInfo$: Observable; + if (settings.useRowStyleFunction === true && isNotEmptyTbFunction(settings.rowStyleFunction)) { + rowStyleInfo$ = compileTbFunction(widgetContext.http, settings.rowStyleFunction, ...args).pipe( + catchError(() => { return of(null) }), + map((rowStyleFunction) => { + if (!rowStyleFunction) { + return { + useRowStyleFunction: false, + rowStyleFunction: null + } + } else { + return { + useRowStyleFunction: true, + rowStyleFunction + } + } + }) + ); + } else { + rowStyleInfo$ = of({ + useRowStyleFunction: false, + rowStyleFunction: null + }); } - return { - useRowStyleFunction, - rowStyleFunction - }; -} - -export function getCellStyleInfo(keySettings: TableWidgetDataKeySettings, ...args: string[]): CellStyleInfo { - let cellStyleFunction: CellStyleFunction = null; - let useCellStyleFunction = false; - - if (keySettings.useCellStyleFunction === true) { - if (isDefined(keySettings.cellStyleFunction) && keySettings.cellStyleFunction.length > 0) { - try { - cellStyleFunction = new Function(...args, keySettings.cellStyleFunction) as CellStyleFunction; - useCellStyleFunction = true; - } catch (e) { - cellStyleFunction = null; - useCellStyleFunction = false; + return rowStyleInfo$.pipe( + share({ + connector: () => new ReplaySubject(1), + resetOnError: false, + resetOnComplete: false, + resetOnRefCountZero: false + }) + ); +} + +export function getCellStyleInfo(widgetContext: WidgetContext, keySettings: TableWidgetDataKeySettings, ...args: string[]): Observable { + let cellStyleInfo$: Observable; + if (keySettings.useCellStyleFunction === true && isNotEmptyTbFunction(keySettings.cellStyleFunction)) { + cellStyleInfo$ = compileTbFunction(widgetContext.http, keySettings.cellStyleFunction, ...args).pipe( + catchError(() => { return of(null) }), + map((cellStyleFunction) => { + if (!cellStyleFunction) { + return { + useCellStyleFunction: false, + cellStyleFunction: null + } + } else { + return { + useCellStyleFunction: true, + cellStyleFunction + } + } + }) + ); + } else { + cellStyleInfo$ = of( + { + useCellStyleFunction: false, + cellStyleFunction: null } - } + ) } - return { - useCellStyleFunction, - cellStyleFunction - }; -} - -export function getCellContentInfo(keySettings: TableWidgetDataKeySettings, ...args: string[]): CellContentInfo { - let cellContentFunction: CellContentFunction = null; - let useCellContentFunction = false; - - if (keySettings.useCellContentFunction === true) { - if (isDefined(keySettings.cellContentFunction) && keySettings.cellContentFunction.length > 0) { - try { - cellContentFunction = new Function(...args, keySettings.cellContentFunction) as CellContentFunction; - useCellContentFunction = true; - } catch (e) { - cellContentFunction = null; - useCellContentFunction = false; + return cellStyleInfo$.pipe( + share({ + connector: () => new ReplaySubject(1), + resetOnError: false, + resetOnComplete: false, + resetOnRefCountZero: false + }) + ); +} + +export function getCellContentFunctionInfo(widgetContext: WidgetContext, keySettings: TableWidgetDataKeySettings, ...args: string[]): Observable { + let cellContentFunctionInfo$: Observable; + if (keySettings.useCellContentFunction === true && isNotEmptyTbFunction(keySettings.cellContentFunction)) { + cellContentFunctionInfo$ = compileTbFunction(widgetContext.http, keySettings.cellContentFunction, ...args).pipe( + catchError(() => { return of(null) }), + map((cellContentFunction) => { + if (!cellContentFunction) { + return { + useCellContentFunction: false, + cellContentFunction: null + } + } else { + return { + useCellContentFunction: true, + cellContentFunction + } + } + }) + ); + } else { + cellContentFunctionInfo$ = of( + { + useCellContentFunction: false, + cellContentFunction: null } - } + ) } - return { - cellContentFunction, - useCellContentFunction - }; + return cellContentFunctionInfo$.pipe( + share({ + connector: () => new ReplaySubject(1), + resetOnError: false, + resetOnComplete: false, + resetOnRefCountZero: false + }) + ); } export function getColumnWidth(keySettings: TableWidgetDataKeySettings): string { @@ -314,20 +372,26 @@ export function getColumnSelectionAvailability(keySettings: TableWidgetDataKeySe return !(isDefined(keySettings.columnSelectionToDisplay) && keySettings.columnSelectionToDisplay === 'disabled'); } -export function getTableCellButtonActions(widgetContext: WidgetContext): TableCellButtonActionDescriptor[] { - return widgetContext.actionsApi.getActionDescriptors('actionCellButton').map(descriptor => { +export function getTableCellButtonActions(widgetContext: WidgetContext): Observable { + const actions$ = widgetContext.actionsApi.getActionDescriptors('actionCellButton').map(descriptor => { let useShowActionCellButtonFunction = descriptor.useShowWidgetActionFunction || false; - let showActionCellButtonFunction: ShowCellButtonActionFunction = null; - if (useShowActionCellButtonFunction && isNotEmptyStr(descriptor.showWidgetActionFunction)) { - try { - showActionCellButtonFunction = - new Function('widgetContext', 'data', descriptor.showWidgetActionFunction) as ShowCellButtonActionFunction; - } catch (e) { - useShowActionCellButtonFunction = false; - } + let showActionCellButtonFunction$: Observable>; + if (useShowActionCellButtonFunction && isNotEmptyTbFunction(descriptor.showWidgetActionFunction)) { + showActionCellButtonFunction$ = compileTbFunction(widgetContext.http, descriptor.showWidgetActionFunction, 'widgetContext', 'data'); + } else { + showActionCellButtonFunction$ = of(null); } - 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 { @@ -348,7 +412,7 @@ function filterTableCellButtonAction(widgetContext: WidgetContext, action: TableCellButtonActionDescriptor, data: EntityData | AlarmDataInfo | FormattedData): boolean { if (action.useShowActionCellButtonFunction) { try { - return action.showActionCellButtonFunction(widgetContext, data); + return action.showActionCellButtonFunction.execute(widgetContext, data); } catch (e) { console.warn('Failed to execute showActionCellButtonFunction', e); return false; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html index 9c45801f29..2867980dc2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html @@ -49,15 +49,15 @@ Timestamp + [innerHTML]="cellContent(source, null, 0, row, row[0], rowIndex) | async" + [style]="cellStyle(source, null, 0, row, row[0], rowIndex) | async"> {{ h.dataKey.label }} + [innerHTML]="cellContent(source, h, h.index, row, row[h.index], rowIndex) | async" + [style]="cellStyle(source, h, h.index, row, row[h.index], rowIndex) | async"> @@ -70,7 +70,7 @@
- +
@@ -110,7 +110,7 @@ ; contentInfo: CellContentInfo; order?: number; } @@ -190,7 +201,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI private displayedColumns: Array = []; - private rowStylesInfo: RowStyleInfo; + private rowStylesInfo: Observable; private subscriptions: 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.rowStylesInfo = getRowStyleInfo(this.settings, 'rowData, ctx'); + this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'rowData, ctx'); const pageSize = this.settings.defaultPageSize; if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { @@ -509,12 +520,15 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI dataKeys.forEach((dataKey, index) => { const sortable = !dataKey.usePostProcessing; const keySettings: TableWidgetDataKeySettings = dataKey.settings; - const styleInfo = getCellStyleInfo(keySettings, 'value, rowData, ctx'); - const contentInfo = getCellContentInfo(keySettings, 'value, rowData, ctx'); + const styleInfo = getCellStyleInfo(this.ctx, keySettings, 'value, rowData, ctx'); + const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, rowData, ctx'); const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx); const columnSelectionAvailability = getColumnSelectionAvailability(keySettings); - contentInfo.units = dataKey.units; - contentInfo.decimals = dataKey.decimals; + const contentInfo: CellContentInfo = { + contentFunction: contentFunctionInfo, + units: dataKey.units, + decimals: dataKey.decimals + }; header.push({ index: index + 1, dataKey, @@ -532,12 +546,15 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI const index = dataKeys.length + latestIndex; const sortable = !dataKey.usePostProcessing; const keySettings: TimeseriesWidgetLatestDataKeySettings = dataKey.settings; - const styleInfo = getCellStyleInfo(keySettings, 'value, rowData, ctx'); - const contentInfo = getCellContentInfo(keySettings, 'value, rowData, ctx'); + const styleInfo = getCellStyleInfo(this.ctx, keySettings, 'value, rowData, ctx'); + const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, rowData, ctx'); const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx); const columnSelectionAvailability = getColumnSelectionAvailability(keySettings); - contentInfo.units = dataKey.units; - contentInfo.decimals = dataKey.decimals; + const contentInfo: CellContentInfo = { + contentFunction: contentFunctionInfo, + units: dataKey.units, + decimals: dataKey.decimals + }; header.push({ index: index + 1, dataKey, @@ -648,111 +665,143 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI return source.datasource.entityId; } - public rowStyle(source: TimeseriesTableSource, row: TimeseriesRow, index: number): any { + public rowStyle(source: TimeseriesTableSource, row: TimeseriesRow, index: number): Observable { + let style$: Observable; let res = this.rowStyleCache[index]; if (!res) { - res = {}; - if (this.rowStylesInfo.useRowStyleFunction && this.rowStylesInfo.rowStyleFunction) { - try { - const rowData = source.rowDataTemplate; - rowData.Timestamp = row[0]; - source.header.forEach((headerInfo) => { - rowData[headerInfo.dataKey.label] = row[headerInfo.index]; - }); - res = this.rowStylesInfo.rowStyleFunction(rowData, this.ctx); - if (!isObject(res)) { - throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); - } - if (Array.isArray(res)) { - throw new TypeError(`Array instead of style object`); + style$ = this.rowStylesInfo.pipe( + map(styleInfo => { + if (styleInfo.useRowStyleFunction && styleInfo.rowStyleFunction) { + const rowData = source.rowDataTemplate; + rowData.Timestamp = row[0]; + source.header.forEach((headerInfo) => { + rowData[headerInfo.dataKey.label] = row[headerInfo.index]; + }); + const style = styleInfo.rowStyleFunction.execute(rowData, this.ctx); + if (!isObject(style)) { + 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 {}; } - } catch (e) { - res = {}; + }), + catchError(e => { console.warn(`Row style function in widget ` + `'${this.ctx.widgetConfig.title}' returns '${e}'. Please check your row style function.`); - } - } - this.rowStyleCache[index] = res; + return of({}); + }) + ); + style$ = style$.pipe( + tap((style) => { + this.rowStyleCache[index] = style; + }) + ); + } else { + style$ = of(res); } - return res; + return style$; } public cellStyle(source: TimeseriesTableSource, header: TimeseriesHeader, - index: number, row: TimeseriesRow, value: any, rowIndex: number): any { + index: number, row: TimeseriesRow, value: any, rowIndex: number): Observable { + let style$: Observable; const cacheIndex = rowIndex * (source.header.length + 1) + index; let res = this.cellStyleCache[cacheIndex]; if (!res) { - res = {}; if (index > 0) { - const styleInfo = header.styleInfo; - if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { - try { - const rowData = source.rowDataTemplate; - rowData.Timestamp = row[0]; - source.header.forEach((headerInfo) => { - rowData[headerInfo.dataKey.label] = row[headerInfo.index]; - }); - res = styleInfo.cellStyleFunction(value, rowData, this.ctx); - if (!isObject(res)) { - throw new TypeError(`${res === null ? 'null' : typeof res} instead of style object`); - } - if (Array.isArray(res)) { - throw new TypeError(`Array instead of style object`); + style$ = header.styleInfo.pipe( + map(styleInfo => { + if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { + const rowData = source.rowDataTemplate; + rowData.Timestamp = row[0]; + source.header.forEach((headerInfo) => { + rowData[headerInfo.dataKey.label] = row[headerInfo.index]; + }); + const style = styleInfo.cellStyleFunction.execute(value, rowData, this.ctx); + if (!isObject(style)) { + 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 {}; } - } catch (e) { - res = {}; + }), + catchError(e => { 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.`); - } - } + 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, - index: number, row: TimeseriesRow, value: any, rowIndex: number): SafeHtml { + index: number, row: TimeseriesRow, value: any, rowIndex: number): Observable { + let content$: Observable; const cacheIndex = rowIndex * (source.header.length + 1) + index ; let res = this.cellContentCache[cacheIndex]; if (isUndefined(res)) { - res = ''; if (index === 0) { - res = row.formattedTs; + content$ = of(row.formattedTs); } else { - let content; - const contentInfo = header.contentInfo; - if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { - try { - const rowData = source.rowDataTemplate; - rowData.Timestamp = row[0]; - source.header.forEach((headerInfo) => { - rowData[headerInfo.dataKey.label] = row[headerInfo.index]; - }); - content = contentInfo.cellContentFunction(value, rowData, this.ctx); - } catch (e) { - content = '' + value; - } - } else { - const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals; - const units = contentInfo.units || this.ctx.widgetConfig.units; - content = this.ctx.utils.formatValue(value, decimals, units, true); - } - - if (isDefined(content)) { - content = this.utils.customTranslation(content, content); - switch (typeof content) { - case 'string': - res = this.domSanitizer.bypassSecurityTrustHtml(content); - break; - default: - res = content; - } - } + content$ = header.contentInfo.contentFunction.pipe( + map((contentFunction) => { + let content: any; + if (contentFunction.useCellContentFunction && contentFunction.cellContentFunction) { + try { + const rowData = source.rowDataTemplate; + rowData.Timestamp = row[0]; + source.header.forEach((headerInfo) => { + rowData[headerInfo.dataKey.label] = row[headerInfo.index]; + }); + content = contentFunction.cellContentFunction.execute(value, rowData, this.ctx); + } catch (e) { + content = '' + value; + } + } else { + const decimals = (header.contentInfo.decimals || header.contentInfo.decimals === 0) ? header.contentInfo.decimals : this.ctx.widgetConfig.decimals; + const units = header.contentInfo.units || this.ctx.widgetConfig.units; + content = this.ctx.utils.formatValue(value, decimals, units, true); + } + if (isDefined(content)) { + content = this.utils.customTranslation(content, content); + switch (typeof content) { + case 'string': + content = this.domSanitizer.bypassSecurityTrustHtml(content); + break; + } + } + 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) { @@ -828,7 +877,8 @@ class TimeseriesDatasource implements DataSource { private reserveSpaceForHiddenAction = true; private cellButtonActions: TableCellButtonActionDescriptor[]; - private readonly usedShowCellActionFunction: boolean; + private usedShowCellActionFunction: boolean; + private inited = false; constructor( private source: TimeseriesTableSource, @@ -837,14 +887,25 @@ class TimeseriesDatasource implements DataSource { private datePipe: DatePipe, private widgetContext: WidgetContext ) { - this.cellButtonActions = getTableCellButtonActions(widgetContext); - this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction); if (this.widgetContext.settings.reserveSpaceForHiddenAction) { this.reserveSpaceForHiddenAction = coerceBooleanProperty(this.widgetContext.settings.reserveSpaceForHiddenAction); } this.source.timeseriesDatasource = this; } + private init(): Observable { + 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> { if (this.rowsSubject.isStopped) { this.rowsSubject.isStopped = false; @@ -886,68 +947,74 @@ class TimeseriesDatasource implements DataSource { } private updateSourceData() { - this.source.data = this.convertData(this.source.rawData, this.source.latestRawData); - 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++) { - const columnData = data[d].data; - columnData.forEach((cellData) => { - const timestamp = cellData[0]; - let row = rowsMap[timestamp]; - if (!row) { - row = { - formattedTs: this.datePipe.transform(timestamp, this.dateFormatFilter) - }; - if (this.cellButtonActions.length) { - if (this.usedShowCellActionFunction) { - const parsedData = formattedDataFormDatasourceData(data, undefined, timestamp); - row.actionCellButtons = prepareTableCellButtonActions(this.widgetContext, this.cellButtonActions, - parsedData[0], this.reserveSpaceForHiddenAction); - row.hasActions = checkHasActions(row.actionCellButtons); - } else { - row.hasActions = true; - row.actionCellButtons = this.cellButtonActions; + this.convertData(this.source.rawData, this.source.latestRawData).subscribe((data) => { + this.source.data = data; + this.allRowsSubject.next(this.source.data); + }); + } + + private convertData(data: DatasourceData[], latestData: DatasourceData[]): Observable { + return this.init().pipe( + map(() => { + const rowsMap: {[timestamp: number]: TimeseriesRow} = {}; + for (let d = 0; d < data.length; d++) { + const columnData = data[d].data; + columnData.forEach((cellData) => { + const timestamp = cellData[0]; + let row = rowsMap[timestamp]; + if (!row) { + row = { + formattedTs: this.datePipe.transform(timestamp, this.dateFormatFilter) + }; + if (this.cellButtonActions.length) { + if (this.usedShowCellActionFunction) { + const parsedData = formattedDataFormDatasourceData(data, undefined, timestamp); + row.actionCellButtons = prepareTableCellButtonActions(this.widgetContext, 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[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[d + 1] = cellData[1]; - }); - } - let rows: TimeseriesRow[] = []; - if (this.hideEmptyLines) { - for (const t of Object.keys(rowsMap)) { - let hideLine = true; - for (let c = 0; (c < data.length) && hideLine; c++) { - if (rowsMap[t][c + 1]) { - hideLine = false; + let rows: TimeseriesRow[] = []; + if (this.hideEmptyLines) { + for (const t of Object.keys(rowsMap)) { + let hideLine = true; + for (let c = 0; (c < data.length) && hideLine; c++) { + if (rowsMap[t][c + 1]) { + hideLine = false; + } + } + if (!hideLine) { + rows.push(rowsMap[t]); + } } + } else { + rows = Object.keys(rowsMap).map(itm => rowsMap[itm]); } - if (!hideLine) { - rows.push(rowsMap[t]); + 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; + }); + } } - } - } 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; + return rows; + }) + ); } isEmpty(): Observable { @@ -963,19 +1030,23 @@ class TimeseriesDatasource implements DataSource { } private fetchRows(pageLink: PageLink): Observable> { - return this.allRows$.pipe( - map((data) => { - const fetchData = pageLink.filterData(data); - if (this.cellButtonActions.length) { - let maxCellButtonAction: number; - if (this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction) { - maxCellButtonAction = Math.max(...fetchData.data.map(tsRow => tsRow.actionCellButtons.length)); - } else { - maxCellButtonAction = this.cellButtonActions.length; - } - this.countCellButtonAction = maxCellButtonAction; - } - return fetchData; + return this.init().pipe( + switchMap(() => { + return this.allRows$.pipe( + map((data) => { + const fetchData = pageLink.filterData(data); + if (this.cellButtonActions.length) { + let maxCellButtonAction: number; + if (this.usedShowCellActionFunction && !this.reserveSpaceForHiddenAction) { + maxCellButtonAction = Math.max(...fetchData.data.map(tsRow => tsRow.actionCellButtons.length)); + } else { + maxCellButtonAction = this.cellButtonActions.length; + } + this.countCellButtonAction = maxCellButtonAction; + } + return fetchData; + }) + ); }) ); } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index ae3cc237ce..6e837b0740 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/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 { basicWidgetConfigComponentsMap } from '@home/components/widget/config/basic/basic-widget-config.module'; 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() export class WidgetComponentService { @@ -82,7 +84,8 @@ export class WidgetComponentService { private widgetService: WidgetService, private utils: UtilsService, private resources: ResourcesService, - private translate: TranslateService) { + private translate: TranslateService, + private http: HttpClient) { this.cssParser.testMode = false; @@ -288,42 +291,45 @@ export class WidgetComponentService { private loadWidget(widgetType: WidgetType, widgetInfoSubject: Subject) { const widgetInfo = toWidgetInfo(widgetType); - let widgetControllerDescriptor: WidgetControllerDescriptor = null; - try { - widgetControllerDescriptor = this.createWidgetControllerDescriptor(widgetInfo); - } catch (e) { - const details = this.utils.parseException(e); - const errorMessage = `Failed to compile widget script. \n Error: ${details.message}`; - this.processWidgetLoadError([errorMessage], widgetInfo.fullFqn, widgetInfoSubject); - } - if (widgetControllerDescriptor) { - const widgetNamespace = `widget-type-${widgetInfo.fullFqn.replace(/\./g, '-')}`; - this.loadWidgetResources(widgetInfo, widgetNamespace, [SharedModule, WidgetComponentsModule, this.homeComponentsModule]).subscribe( - () => { - if (widgetControllerDescriptor.settingsSchema) { - widgetInfo.typeSettingsSchema = widgetControllerDescriptor.settingsSchema; - } - if (widgetControllerDescriptor.dataKeySettingsSchema) { - widgetInfo.typeDataKeySettingsSchema = widgetControllerDescriptor.dataKeySettingsSchema; - } - if (widgetControllerDescriptor.latestDataKeySettingsSchema) { - widgetInfo.typeLatestDataKeySettingsSchema = widgetControllerDescriptor.latestDataKeySettingsSchema; - } - widgetInfo.typeParameters = widgetControllerDescriptor.typeParameters; - widgetInfo.actionSources = widgetControllerDescriptor.actionSources; - widgetInfo.widgetTypeFunction = widgetControllerDescriptor.widgetTypeFunction; - this.widgetService.putWidgetInfoToCache(widgetInfo); - if (widgetInfoSubject) { - widgetInfoSubject.next(widgetInfo); - widgetInfoSubject.complete(); - } - this.resolveWidgetsInfoFetchQueue(widgetInfo.fullFqn, widgetInfo); + this.createWidgetControllerDescriptor(widgetInfo).subscribe( + { + next: widgetControllerDescriptor => { + const widgetNamespace = `widget-type-${widgetInfo.fullFqn.replace(/\./g, '-')}`; + this.loadWidgetResources(widgetInfo, widgetNamespace, [SharedModule, WidgetComponentsModule, this.homeComponentsModule]).subscribe( + { + next: () => { + if (widgetControllerDescriptor.settingsSchema) { + widgetInfo.typeSettingsSchema = widgetControllerDescriptor.settingsSchema; + } + if (widgetControllerDescriptor.dataKeySettingsSchema) { + widgetInfo.typeDataKeySettingsSchema = widgetControllerDescriptor.dataKeySettingsSchema; + } + if (widgetControllerDescriptor.latestDataKeySettingsSchema) { + widgetInfo.typeLatestDataKeySettingsSchema = widgetControllerDescriptor.latestDataKeySettingsSchema; + } + widgetInfo.typeParameters = widgetControllerDescriptor.typeParameters; + widgetInfo.actionSources = widgetControllerDescriptor.actionSources; + widgetInfo.widgetTypeFunction = widgetControllerDescriptor.widgetTypeFunction; + this.widgetService.putWidgetInfoToCache(widgetInfo); + if (widgetInfoSubject) { + widgetInfoSubject.next(widgetInfo); + widgetInfoSubject.complete(); + } + this.resolveWidgetsInfoFetchQueue(widgetInfo.fullFqn, widgetInfo); + }, + error: (errorMessages: string[]) => { + this.processWidgetLoadError(errorMessages, widgetInfo.fullFqn, widgetInfoSubject); + } + } + ); }, - (errorMessages: string[]) => { - this.processWidgetLoadError(errorMessages, widgetInfo.fullFqn, widgetInfoSubject); + error: e => { + 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[]): Observable { @@ -445,7 +451,15 @@ export class WidgetComponentService { } } - private createWidgetControllerDescriptor(widgetInfo: WidgetInfo): WidgetControllerDescriptor { + private createWidgetControllerDescriptor(widgetInfo: WidgetInfo): Observable { + 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` + ' var self = this;\n' + ' self.ctx = ctx;\n\n'; /*+ @@ -505,113 +519,124 @@ export class WidgetComponentService { ' }\n\n' + '}';*/ - widgetTypeFunctionBody += widgetInfo.controllerScript; + widgetTypeFunctionBody += controllerBody; widgetTypeFunctionBody += '\n};\n'; - try { - - const widgetTypeFunction = new Function(widgetTypeFunctionBody); - const widgetType = widgetTypeFunction.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; + let tbWidgetTypeFunction: TbFunction; + if (modules && Object.keys(modules).length) { + tbWidgetTypeFunction = { + body: widgetTypeFunctionBody, + modules } - 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; - } catch (e) { - this.utils.processWidgetException(e); - throw e; + } else { + tbWidgetTypeFunction = widgetTypeFunctionBody; } + + 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) { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 7329eb1eaf..92fc7d7621 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/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 { IModulesMap } from '@modules/common/modules-map.models'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; +import { CompiledTbFunction, compileTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models'; +import { HttpClient } from '@angular/common/http'; @Component({ selector: 'tb-widget', @@ -208,7 +210,8 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, private mobileService: MobileService, private raf: RafService, private ngZone: NgZone, - private cd: ChangeDetectorRef) { + private cd: ChangeDetectorRef, + private http: HttpClient) { super(store); this.cssParser.testMode = false; } @@ -269,37 +272,49 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, }; this.widgetContext.customHeaderActions = []; + const headerActionsDescriptors = this.getActionDescriptors(widgetActionSources.headerButton.value); - headerActionsDescriptors.forEach((descriptor) => - { + + const customHeaderActions$ = headerActionsDescriptors.map((descriptor) => { let useShowWidgetHeaderActionFunction = descriptor.useShowWidgetActionFunction || false; - let showWidgetHeaderActionFunction: ShowWidgetHeaderActionFunction = null; - if (useShowWidgetHeaderActionFunction && isNotEmptyStr(descriptor.showWidgetActionFunction)) { - try { - showWidgetHeaderActionFunction = - new Function('widgetContext', 'data', descriptor.showWidgetActionFunction) as ShowWidgetHeaderActionFunction; - } catch (e) { - useShowWidgetHeaderActionFunction = false; - } + let showWidgetHeaderActionFunction$: Observable>; + if (useShowWidgetHeaderActionFunction && isNotEmptyTbFunction(descriptor.showWidgetActionFunction)) { + showWidgetHeaderActionFunction$ = compileTbFunction(this.http, descriptor.showWidgetActionFunction, 'widgetContext', 'data'); + } else { + showWidgetHeaderActionFunction$ = of(null); } - const headerAction: WidgetHeaderAction = { - name: descriptor.name, - displayName: descriptor.displayName, - icon: descriptor.icon, - descriptor, - useShowWidgetHeaderActionFunction, - showWidgetHeaderActionFunction, - onAction: $event => { - const entityInfo = this.getActiveEntityInfo(); - 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); - } - }; - this.widgetContext.customHeaderActions.push(headerAction); + return showWidgetHeaderActionFunction$.pipe( + catchError(() => { return of(null) }), + map(showWidgetHeaderActionFunction => { + if (!showWidgetHeaderActionFunction) { + useShowWidgetHeaderActionFunction = false; + } + const headerAction: WidgetHeaderAction = { + name: descriptor.name, + displayName: descriptor.displayName, + icon: descriptor.icon, + descriptor, + useShowWidgetHeaderActionFunction, + showWidgetHeaderActionFunction, + onAction: $event => { + const entityInfo = this.getActiveEntityInfo(); + 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.timeService = this.timeService; this.subscriptionContext.deviceService = this.deviceService; @@ -1108,17 +1123,25 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, break; case WidgetActionType.custom: const customFunction = descriptor.customFunction; - if (customFunction && customFunction.length > 0) { - try { - if (!additionalParams) { - additionalParams = {}; + if (isNotEmptyTbFunction(customFunction)) { + compileTbFunction(this.http, customFunction, '$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', 'entityLabel').subscribe( + { + 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; case WidgetActionType.customPretty: @@ -1133,18 +1156,26 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, } this.loadCustomActionResources(actionNamespace, customCss, customResources, descriptor).subscribe({ next: () => { - if (isDefined(customPrettyFunction) && customPrettyFunction.length > 0) { - try { - if (!additionalParams) { - additionalParams = {}; + if (isNotEmptyTbFunction(customPrettyFunction)) { + compileTbFunction(this.http, customPrettyFunction, '$event', 'widgetContext', 'entityId', + 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel').subscribe( + { + 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[]) => { diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index 6658f0b8a4..12c00597d5 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/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 { if (action.useShowWidgetHeaderActionFunction) { try { - return action.showWidgetHeaderActionFunction(this.widgetContext, data); + return action.showWidgetHeaderActionFunction.execute(this.widgetContext, data); } catch (e) { console.warn('Failed to execute showWidgetHeaderActionFunction', e); return false; diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 971c7db6f7..03af49d6d2 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -105,6 +105,7 @@ import { UserId } from '@shared/models/id/user-id'; import { UserSettingsService } from '@core/http/user-settings.service'; import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { UtilsService } from '@core/services/utils.service'; +import { CompiledTbFunction } from '@shared/models/js-function.models'; export interface IWidgetAction { name: string; @@ -118,7 +119,7 @@ export interface WidgetHeaderAction extends IWidgetAction { displayName: string; descriptor: WidgetActionDescriptor; useShowWidgetHeaderActionFunction: boolean; - showWidgetHeaderActionFunction: ShowWidgetHeaderActionFunction; + showWidgetHeaderActionFunction: CompiledTbFunction; } export interface WidgetAction extends IWidgetAction { diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index b914ab8b2a..7920e5b4ae 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/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 { MenuId } from '@core/services/menu.models'; import { catchError } from 'rxjs/operators'; +import { JsLibraryTableConfigResolver } from '@home/pages/admin/resource/js-library-table-config.resolver'; export const scadaSymbolResolver: ResolveFn = (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, + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'javascript.javascript-library' + }, + resolve: { + entitiesTableConfig: JsLibraryTableConfigResolver + } + } + ] } ] }, @@ -393,6 +431,7 @@ const routes: Routes = [ exports: [RouterModule], providers: [ ResourcesLibraryTableConfigResolver, + JsLibraryTableConfigResolver, QueuesTableConfigResolver ] }) diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index 5613e953c3..704f909809 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/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 { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component'; 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({ declarations: @@ -45,17 +48,20 @@ import { OAuth2Module } from '@home/pages/admin/oauth2/oauth2.module'; HomeSettingsComponent, ResourcesLibraryComponent, ResourcesTableHeaderComponent, + JsResourceComponent, + JsLibraryTableHeaderComponent, QueueComponent, RepositoryAdminSettingsComponent, AutoCommitAdminSettingsComponent, TwoFactorAuthSettingsComponent ], - imports: [ - CommonModule, - SharedModule, - HomeComponentsModule, - AdminRoutingModule, - OAuth2Module - ] + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + AdminRoutingModule, + OAuth2Module, + NgxFlowModule + ] }) export class AdminModule { } diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts new file mode 100644 index 0000000000..b0fadd53d9 --- /dev/null +++ b/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 = new EntityTableConfig(); + + constructor(private store: Store, + 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('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('title', 'resource.title', '60%'), + new EntityTableColumn('resourceSubType', 'javascript.javascript-type', '40%', + entity => this.translate.instant(ResourceSubTypeTranslationMap.get(entity.resourceSubType))), + new EntityTableColumn('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 { + 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): 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; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html new file mode 100644 index 0000000000..c69ee84386 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html @@ -0,0 +1,30 @@ + + + javascript.javascript-type + + + {{ "javascript.all-types" | translate }} + + + {{ resourceSubTypesTranslationMap.get(jsResourceSubType) | translate }} + + + diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.ts new file mode 100644 index 0000000000..d3718c3203 --- /dev/null +++ b/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 { + + readonly jsResourceSubTypes: ResourceSubType[] = [ResourceSubType.EXTENSION, ResourceSubType.MODULE]; + readonly resourceSubTypesTranslationMap = ResourceSubTypeTranslationMap; + + constructor(protected store: Store) { + super(store); + } + + jsResourceSubTypeChanged(resourceSubType: ResourceSubType) { + this.entitiesTableConfig.componentsData.resourceSubType = resourceSubType; + this.entitiesTableConfig.getTable().resetSortAndFilter(true); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html new file mode 100644 index 0000000000..d587629352 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html @@ -0,0 +1,117 @@ + +
+ + + +
+ +
+
+
+
+
+ + javascript.javascript-type + + + {{ ResourceSubTypeTranslationMap.get(resourceSubType) | translate }} + + + + + resource.title + + + {{ 'resource.title-required' | translate }} + + + {{ 'resource.title-max-length' | translate }} + + + + + +
+ + + +
+
+
+ + resource.file-name + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts new file mode 100644 index 0000000000..30ab7eb5e0 --- /dev/null +++ b/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 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(); + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: Resource, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + 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; +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html index 0389d32aab..6f60c5b1c5 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html @@ -66,9 +66,9 @@ {{ 'resource.title-max-length' | translate }} - -
+
resource.file-name diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts index e9b602e7d3..df5c311bc9 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts +++ b/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 implements OnInit, OnDestroy { readonly resourceType = ResourceType; - readonly resourceTypes: ResourceType[] = Object.values(this.resourceType); + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize; @@ -80,7 +80,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme buildForm(entity: Resource): FormGroup { return this.fb.group({ 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], data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []] }); @@ -94,9 +94,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme super.updateFormState(); if (this.isEdit && this.entityForm && !this.isAdd) { 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 impleme private observeResourceTypeChange(): void { this.entityForm.get('resourceType').valueChanges.pipe( - startWith(ResourceType.JS_MODULE), + startWith(ResourceType.LWM2M_MODEL), takeUntil(this.destroy$) ).subscribe((type: ResourceType) => this.onResourceTypeChange(type)); } diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts index 85fab10661..c250d68fac 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts +++ b/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 { - readonly resourceTypes: ResourceType[] = Object.values(ResourceType); + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(protected store: Store) { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts index d72d95f367..14fb02f2d2 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -15,7 +15,6 @@ /// import { - AfterViewInit, ChangeDetectorRef, Component, EventEmitter, @@ -68,13 +67,15 @@ import { Authority } from '@shared/models/authority.enum'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { UploadImageDialogComponent, - UploadImageDialogData, UploadImageDialogResult + UploadImageDialogData, + UploadImageDialogResult } from '@shared/components/image/upload-image-dialog.component'; 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 { - SaveWidgetTypeAsDialogComponent, SaveWidgetTypeAsDialogData, + SaveWidgetTypeAsDialogComponent, + SaveWidgetTypeAsDialogData, SaveWidgetTypeAsDialogResult } from '@home/pages/widget/save-widget-type-as-dialog.component'; import { WidgetService } from '@core/http/widget.service'; @@ -86,7 +87,7 @@ import { WidgetService } from '@core/http/widget.service'; encapsulation: ViewEncapsulation.None }) export class ScadaSymbolComponent extends PageComponent - implements OnInit, OnDestroy, AfterViewInit, HasDirtyFlag, ScadaSymbolEditObjectCallbacks { + implements OnInit, OnDestroy, HasDirtyFlag, ScadaSymbolEditObjectCallbacks { widgetType = widgetType; @@ -199,9 +200,6 @@ export class ScadaSymbolComponent extends PageComponent ); } - ngAfterViewInit() { - } - ngOnDestroy() { super.ngOnDestroy(); this.destroy$.next(); @@ -420,10 +418,21 @@ export class ScadaSymbolComponent extends PageComponent const descriptor = widget.descriptor; descriptor.sizeX = metadata.widgetSizeX; descriptor.sizeY = metadata.widgetSizeY; - descriptor.controllerScript = descriptor.controllerScript - .replace(/previewWidth: '\d*px'/gm, `previewWidth: '${metadata.widgetSizeX * 100}px'`); - descriptor.controllerScript = descriptor.controllerScript - .replace(/previewHeight: '\d*px'/gm, `previewHeight: '${metadata.widgetSizeY * 100 + 20}px'`); + let controllerScriptBody: string; + if (typeof descriptor.controllerScript === 'string') { + controllerScriptBody = descriptor.controllerScript; + } 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); config.title = saveWidgetAsData.widgetName; config.settings = config.settings || {}; diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html index a4c8e4a193..416297a80a 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html @@ -308,6 +308,13 @@
+ diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts index 9bd2145c91..03b5674268 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts @@ -21,8 +21,8 @@ import { EventEmitter, Inject, OnDestroy, - OnInit, - ViewChild, + OnInit, Renderer2, + ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; 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 { catchError, map, tap } from 'rxjs/operators'; 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 { 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 @Component({ @@ -160,6 +165,7 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe dataKeyJsonSettingsEditor: Ace.Editor; latestDataKeyJsonSettingsEditor: Ace.Editor; jsEditor: Ace.Editor; + private initialCompleters: Ace.Completer[]; aceResize$: ResizeObserver; onWindowMessageListener = this.onWindowMessage.bind(this); @@ -187,7 +193,11 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe private widgetService: WidgetService, private translate: TranslateService, private raf: RafService, - private dialog: MatDialog) { + private dialog: MatDialog, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private http: HttpClient) { super(store); this.authUser = getCurrentAuthUser(store); @@ -387,15 +397,15 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe this.jsEditor = editor; this.jsEditor.on('input', () => { const editorValue = this.jsEditor.getValue(); - if (this.widget.controllerScript !== editorValue) { - this.widget.controllerScript = editorValue; + if (this.controllerScriptBody !== editorValue) { + this.controllerScriptBody = editorValue; this.isDirty = true; } }); this.jsEditor.on('change', () => { 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.latestDataKeyJsonSettingsEditor.setValue(this.widget.latestDataKeySettingsSchema ? 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 { @@ -781,12 +792,12 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe } 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) => { - if (this.widget.controllerScript !== res) { + if (this.controllerScriptBody !== res) { this.isDirty = true; - this.widget.controllerScript = res; - this.jsEditor.setValue(this.widget.controllerScript ? this.widget.controllerScript : '', -1); + this.controllerScriptBody = res; + this.jsEditor.setValue(this.controllerScriptBody ? this.controllerScriptBody : '', -1); } } ); @@ -862,10 +873,90 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe 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 { if (this.isEditModeWidget && !this._isDirty) { return this.translate.instant('widget.confirm-to-exit-editor-html'); } 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; + }); + } } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts index cb4a3805f3..5d4afd93fc 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts @@ -85,9 +85,7 @@ const widgetEditorCompletions: TbEditorCompletions = { }, ...widgetContextCompletions } - }}, - ...widgetContextCompletions, - ...serviceCompletions + }} }; export const widgetEditorCompleter = new TbEditorCompleter(widgetEditorCompletions); diff --git a/ui-ngx/src/app/shared/components/file-input.component.html b/ui-ngx/src/app/shared/components/file-input.component.html index 6302caa36d..e4fa14f8a9 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.html +++ b/ui-ngx/src/app/shared/components/file-input.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+