Browse Source

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

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

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

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

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

@ -125,6 +125,7 @@ public class ControllerConstants {
protected static final String RESOURCE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the resource title.";
protected static final String RESOURCE_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. ";

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

@ -38,6 +38,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.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<ResourceType> 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);
}

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

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

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

@ -13,6 +13,6 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none
spring.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

2
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

673
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<DataKeyFunction>;
postFuncBody: TbFunction;
postFunc?: CompiledTbFunction<DataKeyPostFunction>;
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<EntityDataLoadResult> {
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<EntityKey> =
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<EntityKey> =
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<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
} else if (this.datasourceType === DatasourceType.entityCount) {
this.latestTsOffset = this.entityDataSubscriptionOptions.latestTsOffset;
this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.subscriber.setTsOffset(this.latestTsOffset);
this.countCommand = new EntityCountCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) {
keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters);
} else {
keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters;
}
}
this.countCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters
};
this.subscriber.subscriptionCommands.push(this.countCommand);
const entityId: EntityId = {
id: NULL_UUID,
entityType: null
};
const countKey = this.dataKeysList[0];
let dataReceived = false;
this.subscriber.entityCount$.subscribe(
(entityCountUpdate) => {
if (!dataReceived) {
const entityData: EntityData = {
entityId,
latest: {
[EntityKeyType.ENTITY_FIELD]: {
name: {
ts: Date.now() + this.latestTsOffset,
value: DatasourceType.entityCount
}
},
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: entityCountUpdate.count + ''
}
}
},
timeseries: {}
};
const pageData: PageData<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
dataReceived = true;
} else {
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<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
} else if (this.datasourceType === DatasourceType.entityCount) {
this.latestTsOffset = this.entityDataSubscriptionOptions.latestTsOffset;
this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.subscriber.setTsOffset(this.latestTsOffset);
this.countCommand = new EntityCountCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) {
keyFilters = keyFilters.concat(this.entityDataSubscriptionOptions.additionalKeyFilters);
} else {
keyFilters = this.entityDataSubscriptionOptions.additionalKeyFilters;
}
}
this.countCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters
};
this.subscriber.subscriptionCommands.push(this.countCommand);
const entityId: EntityId = {
id: NULL_UUID,
entityType: null
};
const countKey = this.dataKeysList[0];
let dataReceived = false;
this.subscriber.entityCount$.subscribe(
(entityCountUpdate) => {
if (!dataReceived) {
const entityData: EntityData = {
entityId,
latest: {
[EntityKeyType.ENTITY_FIELD]: {
name: {
ts: Date.now() + this.latestTsOffset,
value: DatasourceType.entityCount
}
},
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: entityCountUpdate.count + ''
}
}
},
timeseries: {}
};
const pageData: PageData<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
dataReceived = true;
} else {
const update: EntityData[] = [{
entityId,
latest: {
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: entityCountUpdate.count + ''
}
}
},
timeseries: {}
}];
this.onDataUpdate(update);
this.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<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
dataReceived = true;
} else {
const update: EntityData[] = [{
entityId,
latest: {
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: 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<EntityData> = {
data: [entityData],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
dataReceived = true;
} else {
const update: EntityData[] = [{
entityId,
latest: {
[EntityKeyType.COUNT]: {
[countKey.name]: {
ts: Date.now() + this.latestTsOffset,
value: 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],

8
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);
}

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

@ -20,7 +20,7 @@ import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { 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<PageData<ResourceInfo>> {
public getResources(pageLink: PageLink, resourceType?: ResourceType, resourceSubType?: ResourceSubType, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
let url = `/api/resource${pageLink.toQuery()}`;
if (isNotEmptyStr(resourceType)) {
url += `&resourceType=${resourceType}`;
}
if (isNotEmptyStr(resourceSubType)) {
url += `&resourceSubType=${resourceSubType}`;
}
return this.http.get<PageData<ResourceInfo>>(url, defaultHttpOptionsFromConfig(config));
}

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

@ -59,6 +59,7 @@ export enum MenuId {
images = 'images',
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<MenuId, MenuSection>([
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<Authority, MenuReference[]>([
},
{id: MenuId.images},
{id: MenuId.scada_symbols},
{id: MenuId.javascript_library},
{id: MenuId.resources_library}
]
},
@ -803,6 +815,7 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
},
{id: MenuId.images},
{id: MenuId.scada_symbols},
{id: MenuId.javascript_library},
{id: MenuId.resources_library}
]
},

45
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
/// <reference path="../../../../src/typings/rawloader.typings.d.ts" />
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 = /(.*<anonymous>):(\d*)(:)?(\d*)?/g;
const lineInfoGroups = lineInfoRegexp.exec(exception.stack);
if (lineInfoGroups != null && lineInfoGroups.length >= 3) {
if (isUndefined(lineOffset)) {
lineOffset = -2;
}
data.lineNumber = Number(lineInfoGroups[2]) + lineOffset;
if (lineInfoGroups.length >= 5) {
data.columnNumber = Number(lineInfoGroups[4]);
}
}
}
}
}
return data;
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;
}

45
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<T extends GenericFunction>(http: HttpClient, source: TbFunction, params: string[] = ['def']): Observable<CompiledTbFunction<T>> {
if (isNotEmptyTbFunction(source)) {
return compileTbFunction<T>(http, source, ...params).pipe(
catchError(() => {
return of(null);
}),
share({
connector: () => new ReplaySubject(1),
resetOnError: false,
resetOnComplete: false,
resetOnRefCountZero: false
})
);
} else {
return of(null);
}
}
export function safeExecuteTbFunction<T extends GenericFunction>(func: CompiledTbFunction<T>, params = []) {
let res = null;
if (func) {
try {
res = func.execute(...params);
}
catch (err) {
console.log('error in external function:', err);
res = null;
}
}
return res;
}
export function safeExecute(func: (...args: any[]) => any, params = []) {
let res = null;
if (func && typeof (func) === 'function') {

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

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

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

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

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

@ -72,6 +72,7 @@
[functionArgs]="['time', 'prevValue']"
[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">

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

@ -57,6 +57,7 @@ import { coerceBoolean } from '@shared/decorators/coercion';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { 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<void> {
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);
}
}

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

@ -24,7 +24,6 @@ import {
import { WidgetContext } from '@home/models/widget-component.models';
import { 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<V> extends TelemetryValueSetter<V> {
}
const parseError = (ctx: WidgetContext, err: any): string =>
ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error';
const handleRpcError = (ctx: WidgetContext, err: any): Error => {
let reason: string;
if (ctx.defaultSubscription.rpcErrorText) {
reason = ctx.defaultSubscription.rpcErrorText;
} else {
reason = parseError(ctx, err);
reason = parseError(err);
}
return new Error(reason);
};

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

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

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

@ -51,9 +51,9 @@ import cssjs from '@core/css/css';
import { sortItems } from '@shared/models/page/page-link';
import { Direction } from '@shared/models/page/sort-order';
import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections';
import { BehaviorSubject, forkJoin, fromEvent, merge, Observable, Subject, Subscription } from 'rxjs';
import { BehaviorSubject, forkJoin, fromEvent, merge, Observable, of, Subject, Subscription } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { debounceTime, distinctUntilChanged, map, take, takeUntil, tap } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map, take, takeUntil, tap } from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, SortDirection } from '@angular/material/sort';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@ -69,7 +69,7 @@ import {
findEntityKeyByColumnDef,
fromEntityColumnDef,
getAlarmValue,
getCellContentInfo,
getCellContentFunctionInfo,
getCellStyleInfo,
getColumnDefaultVisibility,
getColumnSelectionAvailability,
@ -217,13 +217,13 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
private defaultSortOrder = '-' + alarmFields.createdTime.value;
private contentsInfo: {[key: string]: CellContentInfo} = {};
private stylesInfo: {[key: string]: CellStyleInfo} = {};
private stylesInfo: {[key: string]: Observable<CellStyleInfo>} = {};
private columnWidth: {[key: string]: string} = {};
private columnDefaultVisibility: {[key: string]: boolean} = {};
private columnSelectionAvailability: {[key: string]: boolean} = {};
private columnsWithCellClick: Array<number> = [];
private rowStylesInfo: RowStyleInfo;
private rowStylesInfo: Observable<RowStyleInfo>;
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<any> {
let style$: Observable<any>;
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<any> {
let style$: Observable<any>;
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<SafeHtml> {
let content$: Observable<SafeHtml>;
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<AlarmDataInfo> {
private reserveSpaceForHiddenAction = true;
private cellButtonActions: TableCellButtonActionDescriptor[];
private readonly usedShowCellActionFunction: boolean;
private usedShowCellActionFunction: boolean;
private inited = false;
constructor(private subscription: IWidgetSubscription,
private dataKeys: Array<DataKey>,
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<any> {
if (this.inited) {
return of(null);
}
return getTableCellButtonActions(this.widgetContext).pipe(
tap(actions => {
this.cellButtonActions = this.actionCellDescriptors.concat(actions);
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
this.inited = true;
})
);
}
connect(collectionViewer: CollectionViewer): Observable<AlarmDataInfo[] | ReadonlyArray<AlarmDataInfo>> {
return this.alarmsSubject.asObservable();
}
@ -1237,47 +1288,49 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
}
updateAlarms() {
const subscriptionAlarms = this.subscription.alarms;
let alarms = new Array<AlarmDataInfo>();
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<AlarmDataInfo>();
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<AlarmDataInfo> = {
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<AlarmDataInfo> = {
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;
});
});
}

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

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

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

@ -46,11 +46,11 @@ import { deepClone, hashCode, isDefined, isNumber, isObject, isUndefined } from
import cssjs from '@core/css/css';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { BehaviorSubject, fromEvent, merge, Observable, Subject } from 'rxjs';
import { BehaviorSubject, fromEvent, merge, Observable, of, Subject } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { EntityId } from '@shared/models/id/entity-id';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map, takeUntil, tap } from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, SortDirection } from '@angular/material/sort';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@ -66,7 +66,7 @@ import {
findColumnByEntityKey,
findEntityKeyByColumnDef,
fromEntityColumnDef,
getCellContentInfo,
getCellContentFunctionInfo,
getCellStyleInfo,
getColumnDefaultVisibility,
getColumnSelectionAvailability,
@ -106,6 +106,7 @@ import { hidePageSizePixelValue } from '@shared/models/constants';
import { AggregationType } from '@shared/models/time/time.models';
import { FormBuilder } from '@angular/forms';
import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models';
import { CompiledTbFunction } from '@shared/models/js-function.models';
interface EntitiesTableWidgetSettings extends TableWidgetSettings {
entitiesTitle: string;
@ -164,13 +165,13 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
private defaultSortOrder = 'entityName';
private contentsInfo: {[key: string]: CellContentInfo} = {};
private stylesInfo: {[key: string]: CellStyleInfo} = {};
private stylesInfo: {[key: string]: Observable<CellStyleInfo>} = {};
private columnWidth: {[key: string]: string} = {};
private columnDefaultVisibility: {[key: string]: boolean} = {};
private columnSelectionAvailability: {[key: string]: boolean} = {};
private columnsWithCellClick: Array<number> = [];
private rowStylesInfo: RowStyleInfo;
private rowStylesInfo: Observable<RowStyleInfo>;
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<any> {
let style$: Observable<any>;
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<any> {
let style$: Observable<any>;
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<SafeHtml> {
let content$: Observable<SafeHtml>;
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<EntityData> {
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<EntityData> {
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<any> {
if (this.inited) {
return of(null);
}
return getTableCellButtonActions(this.widgetContext).pipe(
tap(actions => {
this.cellButtonActions = actions
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
this.inited = true;
})
);
}
connect(collectionViewer: CollectionViewer): Observable<EntityData[] | ReadonlyArray<EntityData>> {
return this.entitiesSubject.asObservable();
}
@ -828,36 +893,38 @@ class EntityDatasource implements DataSource<EntityData> {
}
dataUpdated() {
const datasourcesPageData = this.subscription.datasourcePages[0];
const dataPageData = this.subscription.dataPages[0];
let entities = new Array<EntityData>();
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<EntityData>();
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<EntityData> = {
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<EntityData> = {
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;
});
});
}

26
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<CompiledTbFunction<MarkdownTextFunction>>;
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) {

31
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<CompiledTbFunction<QrCodeTextFunction>>;
@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))) {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -26,6 +26,14 @@ import { WidgetContext } from '@home/models/widget-component.models';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { EntityType } from '@shared/models/entity-type.models';
import {
CompiledTbFunction,
compileTbFunction,
isNotEmptyTbFunction,
TbFunction
} from '@shared/models/js-function.models';
import { forkJoin, Observable, of, ReplaySubject } from 'rxjs';
import { catchError, map, share } from 'rxjs/operators';
type ColumnVisibilityOptions = 'visible' | 'hidden' | 'hidden-mobile';
@ -40,7 +48,7 @@ export interface TableWidgetSettings {
displayPagination: boolean;
defaultPageSize: number;
useRowStyleFunction: boolean;
rowStyleFunction?: string;
rowStyleFunction?: TbFunction;
reserveSpaceForHiddenAction?: boolean;
}
@ -48,9 +56,9 @@ export interface TableWidgetDataKeySettings {
customTitle?: string;
columnWidth?: string;
useCellStyleFunction: boolean;
cellStyleFunction?: string;
cellStyleFunction?: TbFunction;
useCellContentFunction: boolean;
cellContentFunction?: string;
cellContentFunction?: TbFunction;
defaultColumnVisibility?: ColumnVisibilityOptions;
columnSelectionToDisplay?: ColumnSelectionOptions;
}
@ -59,7 +67,7 @@ export type ShowCellButtonActionFunction = (ctx: WidgetContext, data: EntityData
export interface TableCellButtonActionDescriptor extends WidgetActionDescriptor {
useShowActionCellButtonFunction: boolean;
showActionCellButtonFunction: ShowCellButtonActionFunction;
showActionCellButtonFunction: CompiledTbFunction<ShowCellButtonActionFunction>;
}
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<CellContentFunction>;
}
export interface CellContentInfo {
contentFunction: Observable<CellContentFunctionInfo>;
units?: string;
decimals?: number;
}
@ -99,14 +111,14 @@ export type CellStyleFunction = (...args: any[]) => any;
export interface CellStyleInfo {
useCellStyleFunction: boolean;
cellStyleFunction?: CellStyleFunction;
cellStyleFunction?: CompiledTbFunction<CellStyleFunction>;
}
export type RowStyleFunction = (...args: any[]) => any;
export interface RowStyleInfo {
useRowStyleFunction: boolean;
rowStyleFunction?: RowStyleFunction;
rowStyleFunction?: CompiledTbFunction<RowStyleFunction>;
}
@ -229,67 +241,113 @@ export function getAlarmValue(alarm: AlarmDataInfo, key: EntityColumn) {
}
}
export function getRowStyleInfo(settings: TableWidgetSettings, ...args: string[]): RowStyleInfo {
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<RowStyleInfo> {
let rowStyleInfo$: Observable<RowStyleInfo>;
if (settings.useRowStyleFunction === true && isNotEmptyTbFunction(settings.rowStyleFunction)) {
rowStyleInfo$ = compileTbFunction<RowStyleFunction>(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<CellStyleInfo> {
let cellStyleInfo$: Observable<CellStyleInfo>;
if (keySettings.useCellStyleFunction === true && isNotEmptyTbFunction(keySettings.cellStyleFunction)) {
cellStyleInfo$ = compileTbFunction<CellStyleFunction>(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<CellContentFunctionInfo> {
let cellContentFunctionInfo$: Observable<CellContentFunctionInfo>;
if (keySettings.useCellContentFunction === true && isNotEmptyTbFunction(keySettings.cellContentFunction)) {
cellContentFunctionInfo$ = compileTbFunction<CellContentFunction>(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<TableCellButtonActionDescriptor[]> {
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<CompiledTbFunction<ShowCellButtonActionFunction>>;
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;

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

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

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

@ -20,7 +20,8 @@ import {
Component,
ElementRef,
Injector,
Input, NgZone,
Input,
NgZone,
OnDestroy,
OnInit,
QueryList,
@ -58,7 +59,17 @@ import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/s
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, fromEvent, merge, Observable, of, Subject, Subscription } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { catchError, debounceTime, distinctUntilChanged, map, skip, startWith, takeUntil } from 'rxjs/operators';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
skip,
startWith,
switchMap,
takeUntil,
tap
} from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@ -68,7 +79,7 @@ import {
checkHasActions,
constructTableCssString,
DisplayColumn,
getCellContentInfo,
getCellContentFunctionInfo,
getCellStyleInfo,
getColumnDefaultVisibility,
getColumnSelectionAvailability,
@ -121,7 +132,7 @@ interface TimeseriesHeader {
show: boolean;
columnDefaultVisibility?: boolean;
columnSelectionAvailability?: boolean;
styleInfo: CellStyleInfo;
styleInfo: Observable<CellStyleInfo>;
contentInfo: CellContentInfo;
order?: number;
}
@ -190,7 +201,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
private displayedColumns: Array<DisplayColumn[]> = [];
private rowStylesInfo: RowStyleInfo;
private rowStylesInfo: Observable<RowStyleInfo>;
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<any> {
let style$: Observable<any>;
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<any> {
let style$: Observable<any>;
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<SafeHtml> {
let content$: Observable<SafeHtml>;
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<TimeseriesRow> {
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<TimeseriesRow> {
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<any> {
if (this.inited) {
return of(null);
}
return getTableCellButtonActions(this.widgetContext).pipe(
tap(actions => {
this.cellButtonActions = actions
this.usedShowCellActionFunction = this.cellButtonActions.some(action => action.useShowActionCellButtonFunction);
this.inited = true;
})
);
}
connect(collectionViewer: CollectionViewer): Observable<TimeseriesRow[] | ReadonlyArray<TimeseriesRow>> {
if (this.rowsSubject.isStopped) {
this.rowsSubject.isStopped = false;
@ -886,68 +947,74 @@ class TimeseriesDatasource implements DataSource<TimeseriesRow> {
}
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<TimeseriesRow[]> {
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<boolean> {
@ -963,19 +1030,23 @@ class TimeseriesDatasource implements DataSource<TimeseriesRow> {
}
private fetchRows(pageLink: PageLink): Observable<PageData<TimeseriesRow>> {
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;
})
);
})
);
}

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

@ -61,6 +61,8 @@ import { HOME_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens';
import { widgetSettingsComponentsMap } from '@home/components/widget/lib/settings/widget-settings.module';
import { 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<WidgetInfo>) {
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<any>[]): Observable<any> {
@ -445,7 +451,15 @@ export class WidgetComponentService {
}
}
private createWidgetControllerDescriptor(widgetInfo: WidgetInfo): WidgetControllerDescriptor {
private createWidgetControllerDescriptor(widgetInfo: WidgetInfo): Observable<WidgetControllerDescriptor> {
let controllerBody: string;
let modules: {[alias: string]: string} = null;
if (typeof widgetInfo.controllerScript === 'string') {
controllerBody = widgetInfo.controllerScript;
} else {
controllerBody = widgetInfo.controllerScript.body;
modules = widgetInfo.controllerScript.modules;
}
let widgetTypeFunctionBody = `return function _${widgetInfo.fullFqn.replace(/\./g, '_')} (ctx) {\n` +
' 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<WidgetInfo>) {

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

@ -120,6 +120,8 @@ import { DASHBOARD_PAGE_COMPONENT_TOKEN } from '@home/components/tokens';
import { MODULES_MAP } from '@shared/models/constants';
import { 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<CompiledTbFunction<ShowWidgetHeaderActionFunction>>;
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[]) => {

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

@ -686,7 +686,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
private filterCustomHeaderAction(action: WidgetHeaderAction, data: FormattedData[]): boolean {
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;

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

@ -105,6 +105,7 @@ import { UserId } from '@shared/models/id/user-id';
import { UserSettingsService } from '@core/http/user-settings.service';
import { 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<ShowWidgetHeaderActionFunction>;
}
export interface WidgetAction extends IWidgetAction {

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

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

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

@ -33,6 +33,9 @@ import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-a
import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component';
import { 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 { }

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -41,7 +41,7 @@ import { getCurrentAuthState } from '@core/auth/auth.selectors';
export class ResourcesLibraryComponent extends EntityComponent<Resource> implements OnInit, OnDestroy {
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<Resource> 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<Resource> 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<Resource> 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));
}

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

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

33
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 || {};

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

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

115
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<Ace.Editor> {
@ -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;
});
}
}

4
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);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

9
ui-ngx/src/form.scss

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

Loading…
Cancel
Save