Browse Source

Merge with master

pull/5398/head
Andrii Shvaika 5 years ago
parent
commit
4ae4c7c381
  1. 1
      application/pom.xml
  2. 11
      application/src/main/java/org/thingsboard/server/controller/AssetController.java
  3. 11
      application/src/main/java/org/thingsboard/server/controller/DeviceController.java
  4. 8
      application/src/main/java/org/thingsboard/server/controller/EdgeController.java
  5. 13
      application/src/main/java/org/thingsboard/server/controller/RuleChainController.java
  6. 53
      application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java
  7. 80
      application/src/main/java/org/thingsboard/server/service/importing/AbstractBulkImportService.java
  8. 14
      application/src/main/java/org/thingsboard/server/service/importing/BulkImportResult.java
  9. 1
      application/src/main/java/org/thingsboard/server/service/importing/ImportedEntityInfo.java
  10. 2
      application/src/main/resources/thingsboard.yml
  11. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java
  12. 13
      common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainImportResult.java
  13. 28
      common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java
  14. 12
      common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java
  15. 22
      dao/pom.xml
  16. 2
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java
  17. 2
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java
  18. 2
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java
  19. 2
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java
  20. 63
      dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
  21. 5
      dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java
  22. 4
      dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java
  23. 3
      dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceProfileRepository.java
  24. 9
      dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java
  25. 9
      dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java
  26. 44
      dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java
  27. 7
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java
  28. 8
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java
  29. 30
      dao/src/test/java/org/thingsboard/server/dao/PostgreSqlDaoServiceTestSuite.java
  30. 64
      dao/src/test/java/org/thingsboard/server/dao/PostgreSqlInitializer.java
  31. 2
      dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceProfileServiceTest.java
  32. 200
      dao/src/test/java/org/thingsboard/server/dao/service/BaseEntityServiceTest.java
  33. 2
      dao/src/test/java/org/thingsboard/server/dao/service/BaseOAuth2ConfigTemplateServiceTest.java
  34. 2
      dao/src/test/java/org/thingsboard/server/dao/service/BaseOAuth2ServiceTest.java
  35. 2
      dao/src/test/java/org/thingsboard/server/dao/service/BaseTenantProfileServiceTest.java
  36. 33
      dao/src/test/java/org/thingsboard/server/dao/service/DaoPostgreSqlTest.java
  37. 8
      dao/src/test/java/org/thingsboard/server/dao/service/psql/EntityServicePostgreSqlTest.java
  38. 69
      dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepositoryTest.java
  39. 47
      dao/src/test/resources/psql-test.properties
  40. 2
      dao/src/test/resources/sql/system-test-psql.sql
  41. 11
      pom.xml
  42. 12
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractAlarmNodeConfiguration.java
  43. 6
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeConfiguration.java
  44. 6
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeConfiguration.java
  45. 68
      rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/clear_alarm_node_script_fn.md
  46. 8
      rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/common_node_script_args.md
  47. 69
      rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/create_alarm_node_script_fn.md
  48. 69
      rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/filter_node_script_fn.md
  49. 118
      rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/generator_node_script_fn.md
  50. 37
      rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/log_node_script_fn.md
  51. 96
      rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/switch_node_script_fn.md
  52. 22
      rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/transformation_node_script_fn.md
  53. 2
      rule-engine/rule-engine-components/src/main/resources/public/static/rulenode/rulenode-core-config.js
  54. 1
      ui-ngx/angular.json
  55. 2
      ui-ngx/package.json
  56. 66
      ui-ngx/src/app/core/services/dynamic-component-factory.service.ts
  57. 29
      ui-ngx/src/app/core/services/help.service.ts
  58. 3
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html
  59. 3
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html
  60. 8
      ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html
  61. 6
      ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts
  62. 1
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  63. 2
      ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.html
  64. 2
      ui-ngx/src/app/modules/home/components/widget/data-key-config.component.html
  65. 2
      ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html
  66. 2
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  67. 1
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html
  68. 18
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.scss
  69. 4
      ui-ngx/src/app/shared/components/help-markdown.component.html
  70. 6
      ui-ngx/src/app/shared/components/help-markdown.component.scss
  71. 14
      ui-ngx/src/app/shared/components/help-markdown.component.ts
  72. 38
      ui-ngx/src/app/shared/components/help-popup.component.html
  73. 34
      ui-ngx/src/app/shared/components/help-popup.component.scss
  74. 62
      ui-ngx/src/app/shared/components/help-popup.component.ts
  75. 5
      ui-ngx/src/app/shared/components/json-form/json-form.component.ts
  76. 26
      ui-ngx/src/app/shared/components/markdown.component.html
  77. 194
      ui-ngx/src/app/shared/components/markdown.component.ts
  78. 30
      ui-ngx/src/app/shared/components/marked-options.service.ts
  79. 164
      ui-ngx/src/app/shared/components/popover.component.ts
  80. 190
      ui-ngx/src/app/shared/components/popover.service.ts
  81. 24
      ui-ngx/src/app/shared/components/tokens.ts
  82. 2
      ui-ngx/src/app/shared/models/constants.ts
  83. 9
      ui-ngx/src/app/shared/shared.module.ts
  84. 19
      ui-ngx/src/assets/help/en_US/widget/action/custom_action_args.md
  85. 81
      ui-ngx/src/assets/help/en_US/widget/action/custom_action_fn.md
  86. 53
      ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md
  87. 85
      ui-ngx/src/assets/help/en_US/widget/action/custom_pretty_action_fn.md
  88. 160
      ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_html.md
  89. 132
      ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_js.md
  90. 192
      ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_html.md
  91. 220
      ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_js.md
  92. 49
      ui-ngx/src/assets/help/en_US/widget/action/mobile_get_location_fn.md
  93. 48
      ui-ngx/src/assets/help/en_US/widget/action/mobile_get_phone_number_fn.md
  94. 31
      ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_empty_result_fn.md
  95. 33
      ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_error_fn.md
  96. 92
      ui-ngx/src/assets/help/en_US/widget/action/mobile_process_image_fn.md
  97. 33
      ui-ngx/src/assets/help/en_US/widget/action/mobile_process_launch_result_fn.md
  98. 59
      ui-ngx/src/assets/help/en_US/widget/action/mobile_process_location_fn.md
  99. 76
      ui-ngx/src/assets/help/en_US/widget/action/mobile_process_qr_code_fn.md
  100. 47
      ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md

1
application/pom.xml

@ -360,6 +360,7 @@
</systemPropertyVariables>
<excludes>
<exclude>**/sql/*Test.java</exclude>
<exclude>**/psql/*Test.java</exclude>
<exclude>**/nosql/*Test.java</exclude>
</excludes>
<includes>

11
application/src/main/java/org/thingsboard/server/controller/AssetController.java

@ -133,7 +133,7 @@ public class AssetController extends BaseController {
Asset savedAsset = checkNotNull(assetService.saveAsset(asset));
onAssetCreatedOrUpdated(savedAsset, asset.getId() != null);
onAssetCreatedOrUpdated(savedAsset, asset.getId() != null, getCurrentUser());
return savedAsset;
} catch (Exception e) {
@ -143,9 +143,9 @@ public class AssetController extends BaseController {
}
}
private void onAssetCreatedOrUpdated(Asset asset, boolean updated) {
private void onAssetCreatedOrUpdated(Asset asset, boolean updated, SecurityUser user) {
try {
logEntityAction(asset.getId(), asset,
logEntityAction(user, asset.getId(), asset,
asset.getCustomerId(),
updated ? ActionType.UPDATED : ActionType.ADDED, null);
} catch (ThingsboardException e) {
@ -656,8 +656,9 @@ public class AssetController extends BaseController {
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
@PostMapping("/asset/bulk_import")
public BulkImportResult<Asset> processAssetsBulkImport(@RequestBody BulkImportRequest request) throws Exception {
return assetBulkImportService.processBulkImport(request, getCurrentUser(), importedAssetInfo -> {
onAssetCreatedOrUpdated(importedAssetInfo.getEntity(), importedAssetInfo.isUpdated());
SecurityUser user = getCurrentUser();
return assetBulkImportService.processBulkImport(request, user, importedAssetInfo -> {
onAssetCreatedOrUpdated(importedAssetInfo.getEntity(), importedAssetInfo.isUpdated(), user);
});
}

11
application/src/main/java/org/thingsboard/server/controller/DeviceController.java

@ -163,7 +163,7 @@ public class DeviceController extends BaseController {
Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken));
onDeviceCreatedOrUpdated(savedDevice, oldDevice, !created);
onDeviceCreatedOrUpdated(savedDevice, oldDevice, !created, getCurrentUser());
return savedDevice;
} catch (Exception e) {
@ -174,11 +174,11 @@ public class DeviceController extends BaseController {
}
private void onDeviceCreatedOrUpdated(Device savedDevice, Device oldDevice, boolean updated) {
private void onDeviceCreatedOrUpdated(Device savedDevice, Device oldDevice, boolean updated, SecurityUser user) {
tbClusterService.onDeviceUpdated(savedDevice, oldDevice);
try {
logEntityAction(savedDevice.getId(), savedDevice,
logEntityAction(user, savedDevice.getId(), savedDevice,
savedDevice.getCustomerId(),
updated ? ActionType.UPDATED : ActionType.ADDED, null);
} catch (ThingsboardException e) {
@ -953,8 +953,9 @@ public class DeviceController extends BaseController {
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
@PostMapping("/device/bulk_import")
public BulkImportResult<Device> processDevicesBulkImport(@RequestBody BulkImportRequest request) throws Exception {
return deviceBulkImportService.processBulkImport(request, getCurrentUser(), importedDeviceInfo -> {
onDeviceCreatedOrUpdated(importedDeviceInfo.getEntity(), importedDeviceInfo.getOldEntity(), importedDeviceInfo.isUpdated());
SecurityUser user = getCurrentUser();
return deviceBulkImportService.processBulkImport(request, user, importedDeviceInfo -> {
onDeviceCreatedOrUpdated(importedDeviceInfo.getEntity(), importedDeviceInfo.getOldEntity(), importedDeviceInfo.isUpdated(), user);
});
}

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

@ -163,7 +163,7 @@ public class EdgeController extends BaseController {
edge.getId(), edge);
Edge savedEdge = checkNotNull(edgeService.saveEdge(edge, true));
onEdgeCreatedOrUpdated(tenantId, savedEdge, edgeTemplateRootRuleChain, !created);
onEdgeCreatedOrUpdated(tenantId, savedEdge, edgeTemplateRootRuleChain, !created, getCurrentUser());
return savedEdge;
} catch (Exception e) {
@ -173,7 +173,7 @@ public class EdgeController extends BaseController {
}
}
private void onEdgeCreatedOrUpdated(TenantId tenantId, Edge edge, RuleChain edgeTemplateRootRuleChain, boolean updated) throws IOException, ThingsboardException {
private void onEdgeCreatedOrUpdated(TenantId tenantId, Edge edge, RuleChain edgeTemplateRootRuleChain, boolean updated, SecurityUser user) throws IOException, ThingsboardException {
if (!updated) {
ruleChainService.assignRuleChainToEdge(tenantId, edgeTemplateRootRuleChain.getId(), edge.getId());
edgeNotificationService.setEdgeRootRuleChain(tenantId, edge, edgeTemplateRootRuleChain.getId());
@ -183,7 +183,7 @@ public class EdgeController extends BaseController {
tbClusterService.broadcastEntityStateChangeEvent(edge.getTenantId(), edge.getId(),
updated ? ComponentLifecycleEvent.UPDATED : ComponentLifecycleEvent.CREATED);
logEntityAction(edge.getId(), edge, null, updated ? ActionType.UPDATED : ActionType.ADDED, null);
logEntityAction(user, edge.getId(), edge, null, updated ? ActionType.UPDATED : ActionType.ADDED, null);
}
@ApiOperation(value = "Delete edge (deleteEdge)",
@ -707,7 +707,7 @@ public class EdgeController extends BaseController {
return edgeBulkImportService.processBulkImport(request, user, importedAssetInfo -> {
try {
onEdgeCreatedOrUpdated(user.getTenantId(), importedAssetInfo.getEntity(), edgeTemplateRootRuleChain, importedAssetInfo.isUpdated());
onEdgeCreatedOrUpdated(user.getTenantId(), importedAssetInfo.getEntity(), edgeTemplateRootRuleChain, importedAssetInfo.isUpdated(), user);
} catch (Exception e) {
throw new RuntimeException(e);
}

13
application/src/main/java/org/thingsboard/server/controller/RuleChainController.java

@ -28,7 +28,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
@ -527,19 +526,21 @@ public class RuleChainController extends BaseController {
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@RequestMapping(value = "/ruleChains/import", method = RequestMethod.POST)
@ResponseBody
public void importRuleChains(
public List<RuleChainImportResult> importRuleChains(
@ApiParam(value = "A JSON value representing the rule chains.")
@RequestBody RuleChainData ruleChainData,
@ApiParam(value = "Enables overwrite for existing rule chains with the same name.")
@RequestParam(required = false, defaultValue = "false") boolean overwrite) throws ThingsboardException {
try {
TenantId tenantId = getCurrentUser().getTenantId();
List<RuleChainImportResult> importResults = ruleChainService.importTenantRuleChains(tenantId, ruleChainData, RuleChainType.CORE, overwrite);
if (!CollectionUtils.isEmpty(importResults)) {
for (RuleChainImportResult importResult : importResults) {
tbClusterService.broadcastEntityStateChangeEvent(importResult.getTenantId(), importResult.getRuleChainId(), importResult.getLifecycleEvent());
List<RuleChainImportResult> importResults = ruleChainService.importTenantRuleChains(tenantId, ruleChainData, overwrite);
for (RuleChainImportResult importResult : importResults) {
if (importResult.getError() == null) {
tbClusterService.broadcastEntityStateChangeEvent(importResult.getTenantId(), importResult.getRuleChainId(),
importResult.isUpdated() ? ComponentLifecycleEvent.UPDATED : ComponentLifecycleEvent.CREATED);
}
}
return importResults;
} catch (Exception e) {
throw handleException(e);
}

53
application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java

@ -63,6 +63,8 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Service
@TbCoreComponent
@ -71,6 +73,8 @@ public class DeviceBulkImportService extends AbstractBulkImportService<Device> {
protected final DeviceCredentialsService deviceCredentialsService;
protected final DeviceProfileService deviceProfileService;
private final Lock findOrCreateDeviceProfileLock = new ReentrantLock();
public DeviceBulkImportService(TelemetrySubscriptionService tsSubscriptionService, TbTenantProfileCache tenantProfileCache,
AccessControlService accessControlService, AccessValidator accessValidator,
EntityActionService entityActionService, TbClusterService clusterService,
@ -106,9 +110,13 @@ public class DeviceBulkImportService extends AbstractBulkImportService<Device> {
throw new DeviceCredentialsValidationException("Invalid device credentials: " + e.getMessage());
}
DeviceProfile deviceProfile;
if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.LWM2M_CREDENTIALS) {
setUpLwM2mDeviceProfile(user.getTenantId(), device);
deviceProfile = setUpLwM2mDeviceProfile(user.getTenantId(), device);
} else {
deviceProfile = deviceProfileService.findOrCreateDeviceProfile(user.getTenantId(), device.getType());
}
device.setDeviceProfileId(deviceProfile.getId());
device = deviceService.saveDeviceWithCredentials(device, deviceCredentials);
@ -215,36 +223,43 @@ public class DeviceBulkImportService extends AbstractBulkImportService<Device> {
credentials.setCredentialsValue(lwm2mCredentials.toString());
}
private void setUpLwM2mDeviceProfile(TenantId tenantId, Device device) {
private DeviceProfile setUpLwM2mDeviceProfile(TenantId tenantId, Device device) {
DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByName(tenantId, device.getType());
if (deviceProfile != null) {
if (deviceProfile.getTransportType() != DeviceTransportType.LWM2M) {
deviceProfile.setTransportType(DeviceTransportType.LWM2M);
deviceProfile.getProfileData().setTransportConfiguration(new Lwm2mDeviceProfileTransportConfiguration());
deviceProfile = deviceProfileService.saveDeviceProfile(deviceProfile);
device.setDeviceProfileId(deviceProfile.getId());
}
} else {
deviceProfile = new DeviceProfile();
deviceProfile.setTenantId(tenantId);
deviceProfile.setType(DeviceProfileType.DEFAULT);
deviceProfile.setName(device.getType());
deviceProfile.setTransportType(DeviceTransportType.LWM2M);
deviceProfile.setProvisionType(DeviceProfileProvisionType.DISABLED);
findOrCreateDeviceProfileLock.lock();
try {
deviceProfile = deviceProfileService.findDeviceProfileByName(tenantId, device.getType());
if (deviceProfile == null) {
deviceProfile = new DeviceProfile();
deviceProfile.setTenantId(tenantId);
deviceProfile.setType(DeviceProfileType.DEFAULT);
deviceProfile.setName(device.getType());
deviceProfile.setTransportType(DeviceTransportType.LWM2M);
deviceProfile.setProvisionType(DeviceProfileProvisionType.DISABLED);
DeviceProfileData deviceProfileData = new DeviceProfileData();
DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration();
DeviceProfileTransportConfiguration transportConfiguration = new Lwm2mDeviceProfileTransportConfiguration();
DisabledDeviceProfileProvisionConfiguration provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(null);
DeviceProfileData deviceProfileData = new DeviceProfileData();
DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration();
DeviceProfileTransportConfiguration transportConfiguration = new Lwm2mDeviceProfileTransportConfiguration();
DisabledDeviceProfileProvisionConfiguration provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(null);
deviceProfileData.setConfiguration(configuration);
deviceProfileData.setTransportConfiguration(transportConfiguration);
deviceProfileData.setProvisionConfiguration(provisionConfiguration);
deviceProfile.setProfileData(deviceProfileData);
deviceProfileData.setConfiguration(configuration);
deviceProfileData.setTransportConfiguration(transportConfiguration);
deviceProfileData.setProvisionConfiguration(provisionConfiguration);
deviceProfile.setProfileData(deviceProfileData);
deviceProfile = deviceProfileService.saveDeviceProfile(deviceProfile);
device.setDeviceProfileId(deviceProfile.getId());
deviceProfile = deviceProfileService.saveDeviceProfile(deviceProfile);
}
} finally {
findOrCreateDeviceProfileLock.unlock();
}
}
return deviceProfile;
}
private void setValues(ObjectNode objectNode, Map<BulkImportColumnType, String> data, Collection<BulkImportColumnType> columns) {

80
application/src/main/java/org/thingsboard/server/service/importing/AbstractBulkImportService.java

@ -22,6 +22,9 @@ import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.thingsboard.common.util.DonAsynchron;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.BaseData;
import org.thingsboard.server.common.data.TenantProfile;
@ -47,11 +50,16 @@ import org.thingsboard.server.utils.CsvUtils;
import org.thingsboard.server.utils.TypeCastUtil;
import javax.annotation.Nullable;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
@ -67,39 +75,49 @@ public abstract class AbstractBulkImportService<E extends BaseData<? extends Ent
protected final EntityActionService entityActionService;
protected final TbClusterService clusterService;
public final BulkImportResult<E> processBulkImport(BulkImportRequest request, SecurityUser user, Consumer<ImportedEntityInfo<E>> onEntityImported) throws Exception {
BulkImportResult<E> result = new BulkImportResult<>();
private static ThreadPoolExecutor executor;
AtomicInteger i = new AtomicInteger(0);
if (request.getMapping().getHeader()) {
i.incrementAndGet();
@PostConstruct
private void initExecutor() {
if (executor == null) {
executor = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(), Runtime.getRuntime().availableProcessors(),
60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(150_000),
ThingsBoardThreadFactory.forName("bulk-import"), new ThreadPoolExecutor.CallerRunsPolicy());
executor.allowCoreThreadTimeOut(true);
}
}
parseData(request).forEach(entityData -> {
i.incrementAndGet();
try {
ImportedEntityInfo<E> importedEntityInfo = saveEntity(request, entityData.getFields(), user);
onEntityImported.accept(importedEntityInfo);
public final BulkImportResult<E> processBulkImport(BulkImportRequest request, SecurityUser user, Consumer<ImportedEntityInfo<E>> onEntityImported) throws Exception {
List<EntityData> entitiesData = parseData(request);
E entity = importedEntityInfo.getEntity();
BulkImportResult<E> result = new BulkImportResult<>();
CountDownLatch completionLatch = new CountDownLatch(entitiesData.size());
saveKvs(user, entity, entityData.getKvs());
entitiesData.forEach(entityData -> DonAsynchron.submit(() -> {
ImportedEntityInfo<E> importedEntityInfo = saveEntity(request, entityData.getFields(), user);
E entity = importedEntityInfo.getEntity();
if (importedEntityInfo.getRelatedError() != null) {
throw new RuntimeException(importedEntityInfo.getRelatedError());
}
onEntityImported.accept(importedEntityInfo);
saveKvs(user, entity, entityData.getKvs());
if (importedEntityInfo.isUpdated()) {
result.setUpdated(result.getUpdated() + 1);
} else {
result.setCreated(result.getCreated() + 1);
}
} catch (Exception e) {
result.setErrors(result.getErrors() + 1);
result.getErrorsList().add(String.format("Line %d: %s", i.get(), e.getMessage()));
}
});
return importedEntityInfo;
},
importedEntityInfo -> {
if (importedEntityInfo.isUpdated()) {
result.getUpdated().incrementAndGet();
} else {
result.getCreated().incrementAndGet();
}
completionLatch.countDown();
},
throwable -> {
result.getErrors().incrementAndGet();
result.getErrorsList().add(String.format("Line %d: %s", entityData.getLineNumber(), ExceptionUtils.getRootCauseMessage(throwable)));
completionLatch.countDown();
},
executor));
completionLatch.await();
return result;
}
@ -186,8 +204,11 @@ public abstract class AbstractBulkImportService<E extends BaseData<? extends Ent
private List<EntityData> parseData(BulkImportRequest request) throws Exception {
List<List<String>> records = CsvUtils.parseCsv(request.getFile(), request.getMapping().getDelimiter());
AtomicInteger linesCounter = new AtomicInteger(0);
if (request.getMapping().getHeader()) {
records.remove(0);
linesCounter.incrementAndGet();
}
List<ColumnMapping> columnsMappings = request.getMapping().getColumns();
@ -205,15 +226,24 @@ public abstract class AbstractBulkImportService<E extends BaseData<? extends Ent
entityData.getKvs().put(entry.getKey(), new ParsedValue(castResult.getValue(), castResult.getKey()));
}
});
entityData.setLineNumber(linesCounter.incrementAndGet());
return entityData;
})
.collect(Collectors.toList());
}
@PreDestroy
private void shutdownExecutor() {
if (!executor.isTerminating()) {
executor.shutdown();
}
}
@Data
protected static class EntityData {
private final Map<BulkImportColumnType, String> fields = new LinkedHashMap<>();
private final Map<ColumnMapping, ParsedValue> kvs = new LinkedHashMap<>();
private int lineNumber;
}
@Data

14
application/src/main/java/org/thingsboard/server/service/importing/BulkImportResult.java

@ -17,14 +17,14 @@ package org.thingsboard.server.service.importing;
import lombok.Data;
import java.util.LinkedList;
import java.util.List;
import java.util.Collection;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.atomic.AtomicInteger;
@Data
public class BulkImportResult<E> {
private int created = 0;
private int updated = 0;
private int errors = 0;
private List<String> errorsList = new LinkedList<>();
private AtomicInteger created = new AtomicInteger();
private AtomicInteger updated = new AtomicInteger();
private AtomicInteger errors = new AtomicInteger();
private Collection<String> errorsList = new ConcurrentLinkedDeque<>();
}

1
application/src/main/java/org/thingsboard/server/service/importing/ImportedEntityInfo.java

@ -22,5 +22,4 @@ public class ImportedEntityInfo<E> {
private E entity;
private boolean isUpdated;
private E oldEntity;
private String relatedError;
}

2
application/src/main/resources/thingsboard.yml

@ -279,6 +279,8 @@ sql:
rpc:
enabled: "${SQL_TTL_RPC_ENABLED:true}"
checking_interval: "${SQL_RPC_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours
relations:
max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # //This value has to be reasonable small to prevent infinite recursion as early as possible
# Actor system parameters
actors:

3
common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java

@ -23,7 +23,6 @@ import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainData;
@ -71,7 +70,7 @@ public interface RuleChainService {
RuleChainData exportTenantRuleChains(TenantId tenantId, PageLink pageLink) throws ThingsboardException;
List<RuleChainImportResult> importTenantRuleChains(TenantId tenantId, RuleChainData ruleChainData, RuleChainType type, boolean overwrite);
List<RuleChainImportResult> importTenantRuleChains(TenantId tenantId, RuleChainData ruleChainData, boolean overwrite);
RuleChain assignRuleChainToEdge(TenantId tenantId, RuleChainId ruleChainId, EdgeId edgeId);

13
common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainImportResult.java

@ -15,17 +15,22 @@
*/
package org.thingsboard.server.common.data.rule;
import lombok.AllArgsConstructor;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
@Data
@AllArgsConstructor
public class RuleChainImportResult {
@JsonIgnore
private TenantId tenantId;
private RuleChainId ruleChainId;
private ComponentLifecycleEvent lifecycleEvent;
private String ruleChainName;
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
private boolean updated;
@JsonInclude(JsonInclude.Include.NON_NULL)
private String error;
}

28
common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java

@ -0,0 +1,28 @@
/**
* Copyright © 2016-2021 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.id;
import org.junit.Assert;
import org.junit.Test;
public class EntityIdTest {
@Test
public void givenConstantNullUuid_whenCompare_thenToStringEqualsPredefinedUuid() {
Assert.assertEquals("13814000-1dd2-11b2-8080-808080808080", EntityId.NULL_UUID.toString());
}
}

12
common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java

@ -20,6 +20,7 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
@ -53,4 +54,15 @@ public class DonAsynchron {
Futures.addCallback(future, callback, MoreExecutors.directExecutor());
}
}
public static <T> ListenableFuture<T> submit(Callable<T> task, Consumer<T> onSuccess, Consumer<Throwable> onFailure, Executor executor) {
return submit(task, onSuccess, onFailure, executor, null);
}
public static <T> ListenableFuture<T> submit(Callable<T> task, Consumer<T> onSuccess, Consumer<Throwable> onFailure, Executor executor, Executor callbackExecutor) {
ListenableFuture<T> future = Futures.submit(task, executor);
withCallback(future, onSuccess, onFailure, callbackExecutor);
return future;
}
}

22
dao/pom.xml

@ -201,6 +201,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
@ -211,6 +216,16 @@
<artifactId>hsqldb</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>jdbc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
@ -239,7 +254,14 @@
<artifactId>maven-surefire-plugin</artifactId>
<version>${surfire.version}</version>
<configuration>
<excludes>
<exclude>**/sql/*Test.java</exclude>
<exclude>**/sql/*/*Test.java</exclude>
<exclude>**/psql/*Test.java</exclude>
<exclude>**/nosql/*Test.java</exclude>
</excludes>
<includes>
<include>**/*Test.java</include>
<include>**/*TestSuite.java</include>
</includes>
</configuration>

2
dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java

@ -35,6 +35,8 @@ public interface DeviceCredentialsDao extends Dao<DeviceCredentials> {
*/
DeviceCredentials save(TenantId tenantId, DeviceCredentials deviceCredentials);
DeviceCredentials saveAndFlush(TenantId tenantId, DeviceCredentials deviceCredentials);
/**
* Find device credentials by device id.
*

2
dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java

@ -96,7 +96,7 @@ public class DeviceCredentialsServiceImpl extends AbstractEntityService implemen
log.trace("Executing updateDeviceCredentials [{}]", deviceCredentials);
credentialsValidator.validate(deviceCredentials, id -> tenantId);
try {
return deviceCredentialsDao.save(tenantId, deviceCredentials);
return deviceCredentialsDao.saveAndFlush(tenantId, deviceCredentials);
} catch (Exception t) {
ConstraintViolationException e = extractConstraintViolationException(t).orElse(null);
if (e != null && e.getConstraintName() != null

2
dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java

@ -30,6 +30,8 @@ public interface DeviceProfileDao extends Dao<DeviceProfile> {
DeviceProfile save(TenantId tenantId, DeviceProfile deviceProfile);
DeviceProfile saveAndFlush(TenantId tenantId, DeviceProfile deviceProfile);
PageData<DeviceProfile> findDeviceProfiles(TenantId tenantId, PageLink pageLink);
PageData<DeviceProfileInfo> findDeviceProfileInfos(TenantId tenantId, PageLink pageLink, String transportType);

2
dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java

@ -167,7 +167,7 @@ public class DeviceProfileServiceImpl extends AbstractEntityService implements D
}
DeviceProfile savedDeviceProfile;
try {
savedDeviceProfile = deviceProfileDao.save(deviceProfile.getTenantId(), deviceProfile);
savedDeviceProfile = deviceProfileDao.saveAndFlush(deviceProfile.getTenantId(), deviceProfile);
} catch (Exception t) {
ConstraintViolationException e = extractConstraintViolationException(t).orElse(null);
if (e != null && e.getConstraintName() != null && e.getConstraintName().equalsIgnoreCase("device_profile_name_unq_key")) {

63
dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java

@ -22,6 +22,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
@ -38,7 +39,6 @@ import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.rule.NodeConnectionInfo;
@ -59,6 +59,7 @@ import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantDao;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
@ -416,41 +417,46 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
}
@Override
public List<RuleChainImportResult> importTenantRuleChains(TenantId tenantId, RuleChainData ruleChainData, RuleChainType type, boolean overwrite) {
public List<RuleChainImportResult> importTenantRuleChains(TenantId tenantId, RuleChainData ruleChainData, boolean overwrite) {
List<RuleChainImportResult> importResults = new ArrayList<>();
setRandomRuleChainIds(ruleChainData);
resetRuleNodeIds(ruleChainData.getMetadata());
resetRuleChainMetadataTenantIds(tenantId, ruleChainData.getMetadata());
if (overwrite) {
List<RuleChain> persistentRuleChains = findAllTenantRuleChains(tenantId, type);
for (RuleChain ruleChain : ruleChainData.getRuleChains()) {
ComponentLifecycleEvent lifecycleEvent;
Optional<RuleChain> persistentRuleChainOpt = persistentRuleChains.stream().filter(rc -> rc.getName().equals(ruleChain.getName())).findFirst();
if (persistentRuleChainOpt.isPresent()) {
setNewRuleChainId(ruleChain, ruleChainData.getMetadata(), ruleChain.getId(), persistentRuleChainOpt.get().getId());
ruleChain.setRoot(persistentRuleChainOpt.get().isRoot());
lifecycleEvent = ComponentLifecycleEvent.UPDATED;
} else {
ruleChain.setRoot(false);
lifecycleEvent = ComponentLifecycleEvent.CREATED;
for (RuleChain ruleChain : ruleChainData.getRuleChains()) {
RuleChainImportResult importResult = new RuleChainImportResult();
ruleChain.setTenantId(tenantId);
ruleChain.setRoot(false);
if (overwrite) {
Collection<RuleChain> existingRuleChains = ruleChainDao.findByTenantIdAndTypeAndName(tenantId,
Optional.ofNullable(ruleChain.getType()).orElse(RuleChainType.CORE), ruleChain.getName());
Optional<RuleChain> existingRuleChain = existingRuleChains.stream().findFirst();
if (existingRuleChain.isPresent()) {
setNewRuleChainId(ruleChain, ruleChainData.getMetadata(), ruleChain.getId(), existingRuleChain.get().getId());
ruleChain.setRoot(existingRuleChain.get().isRoot());
importResult.setUpdated(true);
}
ruleChain.setTenantId(tenantId);
ruleChainDao.save(tenantId, ruleChain);
importResults.add(new RuleChainImportResult(tenantId, ruleChain.getId(), lifecycleEvent));
}
} else {
if (!CollectionUtils.isEmpty(ruleChainData.getRuleChains())) {
ruleChainData.getRuleChains().forEach(rc -> {
rc.setTenantId(tenantId);
rc.setRoot(false);
RuleChain savedRc = ruleChainDao.save(tenantId, rc);
importResults.add(new RuleChainImportResult(tenantId, savedRc.getId(), ComponentLifecycleEvent.CREATED));
});
try {
ruleChain = saveRuleChain(ruleChain);
} catch (Exception e) {
importResult.setError(ExceptionUtils.getRootCauseMessage(e));
}
importResult.setTenantId(tenantId);
importResult.setRuleChainId(ruleChain.getId());
importResult.setRuleChainName(ruleChain.getName());
importResults.add(importResult);
}
if (!CollectionUtils.isEmpty(ruleChainData.getMetadata())) {
if (CollectionUtils.isNotEmpty(ruleChainData.getMetadata())) {
ruleChainData.getMetadata().forEach(md -> saveRuleChainMetaData(tenantId, md));
}
return importResults;
}
@ -475,7 +481,9 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
}
if (isTenantId) {
ObjectNode objNode = (ObjectNode) node;
objNode.put("id", tenantId.getId().toString());
if (objNode.has("id")) {
objNode.put("id", tenantId.getId().toString());
}
} else {
for (JsonNode jsonNode : node) {
searchTenantIdRecursive(tenantId, jsonNode);
@ -726,4 +734,5 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
checkRuleNodesAndDelete(tenantId, entity.getId());
}
};
}

5
dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.dao.rule;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
@ -22,6 +23,7 @@ import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.TenantEntityDao;
import java.util.Collection;
import java.util.UUID;
/**
@ -74,4 +76,7 @@ public interface RuleChainDao extends Dao<RuleChain>, TenantEntityDao {
* @return the list of rule chain objects
*/
PageData<RuleChain> findAutoAssignToEdgeRuleChainsByTenantId(UUID tenantId, PageLink pageLink);
Collection<RuleChain> findByTenantIdAndTypeAndName(TenantId tenantId, RuleChainType type, String name);
}

4
dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java

@ -15,7 +15,7 @@
*/
package org.thingsboard.server.dao.sql.device;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.thingsboard.server.dao.model.sql.DeviceCredentialsEntity;
import java.util.UUID;
@ -23,7 +23,7 @@ import java.util.UUID;
/**
* Created by Valerii Sosliuk on 5/6/2017.
*/
public interface DeviceCredentialsRepository extends CrudRepository<DeviceCredentialsEntity, UUID> {
public interface DeviceCredentialsRepository extends JpaRepository<DeviceCredentialsEntity, UUID> {
DeviceCredentialsEntity findByDeviceId(UUID deviceId);

3
dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceProfileRepository.java

@ -17,6 +17,7 @@ package org.thingsboard.server.dao.sql.device;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.PagingAndSortingRepository;
import org.springframework.data.repository.query.Param;
@ -26,7 +27,7 @@ import org.thingsboard.server.dao.model.sql.DeviceProfileEntity;
import java.util.UUID;
public interface DeviceProfileRepository extends PagingAndSortingRepository<DeviceProfileEntity, UUID> {
public interface DeviceProfileRepository extends JpaRepository<DeviceProfileEntity, UUID> {
@Query("SELECT new org.thingsboard.server.common.data.DeviceProfileInfo(d.id, d.name, d.image, d.defaultDashboardId, d.type, d.transportType) " +
"FROM DeviceProfileEntity d " +

9
dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java

@ -18,6 +18,7 @@ package org.thingsboard.server.dao.sql.device;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.dao.DaoUtil;
@ -46,6 +47,14 @@ public class JpaDeviceCredentialsDao extends JpaAbstractDao<DeviceCredentialsEnt
return deviceCredentialsRepository;
}
@Transactional
@Override
public DeviceCredentials saveAndFlush(TenantId tenantId, DeviceCredentials deviceCredentials) {
DeviceCredentials result = save(tenantId, deviceCredentials);
deviceCredentialsRepository.flush();
return result;
}
@Override
public DeviceCredentials findByDeviceId(TenantId tenantId, UUID deviceId) {
return DaoUtil.getData(deviceCredentialsRepository.findByDeviceId(deviceId));

9
dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java

@ -19,6 +19,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceProfileInfo;
import org.thingsboard.server.common.data.DeviceTransportType;
@ -54,6 +55,14 @@ public class JpaDeviceProfileDao extends JpaAbstractSearchTextDao<DeviceProfileE
return deviceProfileRepository.findDeviceProfileInfoById(deviceProfileId);
}
@Transactional
@Override
public DeviceProfile saveAndFlush(TenantId tenantId, DeviceProfile deviceProfile) {
DeviceProfile result = save(tenantId, deviceProfile);
deviceProfileRepository.flush();
return result;
}
@Override
public PageData<DeviceProfile> findDeviceProfiles(TenantId tenantId, PageLink pageLink) {
return DaoUtil.toPageData(

44
dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java

@ -15,8 +15,10 @@
*/
package org.thingsboard.server.dao.sql.query;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionTemplate;
@ -239,22 +241,38 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
public static EntityType[] RELATION_QUERY_ENTITY_TYPES = new EntityType[]{
EntityType.TENANT, EntityType.CUSTOMER, EntityType.USER, EntityType.DASHBOARD, EntityType.ASSET, EntityType.DEVICE, EntityType.ENTITY_VIEW};
private static final String HIERARCHICAL_QUERY_TEMPLATE = " FROM (WITH RECURSIVE related_entities(from_id, from_type, to_id, to_type, relation_type, lvl) AS (" +
" SELECT from_id, from_type, to_id, to_type, relation_type, 1 as lvl" +
" FROM relation" +
private static final String HIERARCHICAL_QUERY_TEMPLATE = " FROM (WITH RECURSIVE related_entities(from_id, from_type, to_id, to_type, lvl, path) AS (" +
" SELECT from_id, from_type, to_id, to_type," +
" 1 as lvl," +
" ARRAY[$in_id] as path" + // initial path
" FROM relation " +
" WHERE $in_id = :relation_root_id and $in_type = :relation_root_type and relation_type_group = 'COMMON'" +
" GROUP BY from_id, from_type, to_id, to_type, lvl, path" +
" UNION ALL" +
" SELECT r.from_id, r.from_type, r.to_id, r.to_type, r.relation_type, lvl + 1" +
" SELECT r.from_id, r.from_type, r.to_id, r.to_type," +
" (re.lvl + 1) as lvl, " +
" (re.path || ARRAY[r.$in_id]) as path" +
" FROM relation r" +
" INNER JOIN related_entities re ON" +
" r.$in_id = re.$out_id and r.$in_type = re.$out_type and" +
" relation_type_group = 'COMMON' %s)" +
" SELECT re.$out_id entity_id, re.$out_type entity_type, max(re.lvl) lvl" +
" from related_entities re" +
" relation_type_group = 'COMMON' " +
" AND r.$in_id NOT IN (SELECT * FROM unnest(re.path)) " +
" %s" +
" GROUP BY r.from_id, r.from_type, r.to_id, r.to_type, (re.lvl + 1), (re.path || ARRAY[r.$in_id])" +
" )" +
" SELECT re.$out_id entity_id, re.$out_type entity_type, max(r_int.lvl) lvl" +
" from related_entities r_int" +
" INNER JOIN relation re ON re.from_id = r_int.from_id AND re.from_type = r_int.from_type" +
" AND re.to_id = r_int.to_id AND re.to_type = r_int.to_type" +
" AND re.relation_type_group = 'COMMON'" +
" %s GROUP BY entity_id, entity_type) entity";
private static final String HIERARCHICAL_TO_QUERY_TEMPLATE = HIERARCHICAL_QUERY_TEMPLATE.replace("$in", "to").replace("$out", "from");
private static final String HIERARCHICAL_FROM_QUERY_TEMPLATE = HIERARCHICAL_QUERY_TEMPLATE.replace("$in", "from").replace("$out", "to");
@Getter
@Value("${sql.relations.max_level:50}")
int maxLevelAllowed; //This value has to be reasonable small to prevent infinite recursion as early as possible
private final NamedParameterJdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate;
private final DefaultQueryLogComponent queryLog;
@ -580,7 +598,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
.append("nr.").append(fromOrTo).append("_type").append(" = re.").append(toOrFrom).append("_type");
notExistsPart.append(")");
whereFilter += " and ( re.lvl = " + entityFilter.getMaxLevel() + " OR " + notExistsPart.toString() + ")";
whereFilter += " and ( r_int.lvl = " + entityFilter.getMaxLevel() + " OR " + notExistsPart.toString() + ")";
}
from = String.format(from, lvlFilter, whereFilter);
String query = "( " + selectFields + from + ")";
@ -659,7 +677,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
.append(whereFilter.toString().replaceAll("re\\.", "nr\\."));
notExistsPart.append(")");
whereFilter.append(" and ( re.lvl = ").append(entityFilter.getMaxLevel()).append(" OR ").append(notExistsPart.toString()).append(")");
whereFilter.append(" and ( r_int.lvl = ").append(entityFilter.getMaxLevel()).append(" OR ").append(notExistsPart.toString()).append(")");
}
from = String.format(from, lvlFilter, " WHERE " + whereFilter);
return "( " + selectFields + from + ")";
@ -693,8 +711,12 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
return whereFilter.toString();
}
private String getLvlFilter(int maxLevel) {
return maxLevel > 0 ? ("and lvl <= " + (maxLevel - 1)) : "";
String getLvlFilter(int maxLevel) {
return "and re.lvl <= " + (getMaxLevel(maxLevel) - 1);
}
int getMaxLevel(int maxLevel) {
return (maxLevel <= 0 || maxLevel > this.maxLevelAllowed) ? this.maxLevelAllowed : maxLevel;
}
private String getQueryTemplate(EntitySearchDirection direction) {

7
dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java

@ -29,6 +29,7 @@ import org.thingsboard.server.dao.model.sql.RuleChainEntity;
import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.sql.JpaAbstractSearchTextDao;
import java.util.Collection;
import java.util.Objects;
import java.util.UUID;
@ -97,8 +98,14 @@ public class JpaRuleChainDao extends JpaAbstractSearchTextDao<RuleChainEntity, R
DaoUtil.toPageable(pageLink)));
}
@Override
public Collection<RuleChain> findByTenantIdAndTypeAndName(TenantId tenantId, RuleChainType type, String name) {
return DaoUtil.convertDataList(ruleChainRepository.findByTenantIdAndTypeAndName(tenantId.getId(), type, name));
}
@Override
public Long countByTenantId(TenantId tenantId) {
return ruleChainRepository.countByTenantId(tenantId.getId());
}
}

8
dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java

@ -23,6 +23,7 @@ import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.model.sql.RuleChainEntity;
import java.util.List;
import java.util.UUID;
public interface RuleChainRepository extends PagingAndSortingRepository<RuleChainEntity, UUID> {
@ -55,10 +56,13 @@ public interface RuleChainRepository extends PagingAndSortingRepository<RuleChai
"AND re.relationType = 'Contains' AND re.fromId = :tenantId AND re.fromType = 'TENANT' " +
"AND LOWER(rc.searchText) LIKE LOWER(CONCAT(:searchText, '%'))")
Page<RuleChainEntity> findAutoAssignByTenantId(@Param("tenantId") UUID tenantId,
@Param("searchText") String searchText,
Pageable pageable);
@Param("searchText") String searchText,
Pageable pageable);
RuleChainEntity findByTenantIdAndTypeAndRootIsTrue(UUID tenantId, RuleChainType ruleChainType);
Long countByTenantId(UUID tenantId);
List<RuleChainEntity> findByTenantIdAndTypeAndName(UUID tenantId, RuleChainType type, String name);
}

30
dao/src/test/java/org/thingsboard/server/dao/PostgreSqlDaoServiceTestSuite.java

@ -0,0 +1,30 @@
/**
* Copyright © 2016-2021 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao;
import org.junit.extensions.cpsuite.ClasspathSuite;
import org.junit.extensions.cpsuite.ClasspathSuite.ClassnameFilters;
import org.junit.runner.RunWith;
@RunWith(ClasspathSuite.class)
@ClassnameFilters({
"org.thingsboard.server.dao.service.psql.*SqlTest",
"org.thingsboard.server.dao.service.attributes.psql.*SqlTest",
"org.thingsboard.server.dao.service.event.psql.*SqlTest",
"org.thingsboard.server.dao.service.timeseries.psql.*SqlTest"
})
public class PostgreSqlDaoServiceTestSuite {
}

64
dao/src/test/java/org/thingsboard/server/dao/PostgreSqlInitializer.java

@ -0,0 +1,64 @@
/**
* Copyright © 2016-2021 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao;
import com.google.common.base.Charsets;
import com.google.common.io.Resources;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.URL;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;
@Slf4j
public class PostgreSqlInitializer {
private static final List<String> sqlFiles = List.of(
"sql/schema-ts-psql.sql",
"sql/schema-entities.sql",
"sql/schema-entities-idx.sql",
"sql/system-data.sql",
"sql/system-test-psql.sql");
private static final String dropAllTablesSqlFile = "sql/psql/drop-all-tables.sql";
public static void initDb(Connection conn) {
cleanUpDb(conn);
log.info("initialize Postgres DB...");
try {
for (String sqlFile : sqlFiles) {
URL sqlFileUrl = Resources.getResource(sqlFile);
String sql = Resources.toString(sqlFileUrl, Charsets.UTF_8);
conn.createStatement().execute(sql);
}
} catch (IOException | SQLException e) {
throw new RuntimeException("Unable to init the Postgres database. Reason: " + e.getMessage(), e);
}
log.info("Postgres DB is initialized!");
}
private static void cleanUpDb(Connection conn) {
log.info("clean up Postgres DB...");
try {
URL dropAllTableSqlFileUrl = Resources.getResource(dropAllTablesSqlFile);
String dropAllTablesSql = Resources.toString(dropAllTableSqlFileUrl, Charsets.UTF_8);
conn.createStatement().execute(dropAllTablesSql);
} catch (IOException | SQLException e) {
throw new RuntimeException("Unable to clean up the Postgres database. Reason: " + e.getMessage(), e);
}
}
}

2
dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceProfileServiceTest.java

@ -47,7 +47,7 @@ import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.ota.OtaPackageType.FIRMWARE;
public class BaseDeviceProfileServiceTest extends AbstractServiceTest {
public abstract class BaseDeviceProfileServiceTest extends AbstractServiceTest {
private IdComparator<DeviceProfile> idComparator = new IdComparator<>();
private IdComparator<DeviceProfileInfo> deviceProfileInfoIdComparator = new IdComparator<>();

200
dao/src/test/java/org/thingsboard/server/dao/service/BaseEntityServiceTest.java

@ -17,14 +17,17 @@ package org.thingsboard.server.dao.service;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.RandomUtils;
import org.hamcrest.Matchers;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.ResultSetExtractor;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
@ -69,6 +72,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.model.sqlts.ts.TsKvEntity;
import org.thingsboard.server.dao.sql.relation.RelationRepository;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import java.util.ArrayList;
@ -82,9 +86,13 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.hamcrest.MatcherAssert.assertThat;
@Slf4j
public abstract class BaseEntityServiceTest extends AbstractServiceTest {
static final int ENTITY_COUNT = 5;
@Autowired
private AttributesService attributesService;
@ -96,6 +104,9 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
@Autowired
private JdbcTemplate template;
@Autowired
private RelationRepository relationRepository;
@Before
public void before() {
Tenant tenant = new Tenant();
@ -110,7 +121,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
tenantService.deleteTenant(tenantId);
}
@Test
public void testCountEntitiesByQuery() throws InterruptedException {
List<Device> devices = new ArrayList<>();
@ -154,12 +165,12 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
Assert.assertEquals(0, count);
}
@Test
public void testCountHierarchicalEntitiesByQuery() throws InterruptedException {
List<Asset> assets = new ArrayList<>();
List<Device> devices = new ArrayList<>();
createTestHierarchy(assets, devices, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
createTestHierarchy(tenantId, assets, devices, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
RelationsQueryFilter filter = new RelationsQueryFilter();
filter.setRootEntity(tenantId);
@ -168,7 +179,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
EntityCountQuery countQuery = new EntityCountQuery(filter);
long count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery);
Assert.assertEquals(30, count);
Assert.assertEquals(31, count); //due to the loop relations in hierarchy, the TenantId included in total count (1*Tenant + 5*Asset + 5*5*Devices = 31)
filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Contains", Collections.singletonList(EntityType.DEVICE))));
count = entityService.countEntitiesByQuery(tenantId, new CustomerId(CustomerId.NULL_UUID), countQuery);
@ -304,11 +315,25 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
@Test
public void testHierarchicalFindEntityDataWithAttributesByQuery() throws ExecutionException, InterruptedException {
doTestHierarchicalFindEntityDataWithAttributesByQuery(0, false);
}
@Test
public void testHierarchicalFindEntityDataWithAttributesByQueryWithLevel() throws ExecutionException, InterruptedException {
doTestHierarchicalFindEntityDataWithAttributesByQuery(2, false);
}
@Test
public void testHierarchicalFindEntityDataWithAttributesByQueryWithLastLevelOnly() throws ExecutionException, InterruptedException {
doTestHierarchicalFindEntityDataWithAttributesByQuery(2, true);
}
private void doTestHierarchicalFindEntityDataWithAttributesByQuery(final int maxLevel, final boolean fetchLastLevelOnly) throws ExecutionException, InterruptedException {
List<Asset> assets = new ArrayList<>();
List<Device> devices = new ArrayList<>();
List<Long> temperatures = new ArrayList<>();
List<Long> highTemperatures = new ArrayList<>();
createTestHierarchy(assets, devices, new ArrayList<>(), new ArrayList<>(), temperatures, highTemperatures);
createTestHierarchy(tenantId, assets, devices, new ArrayList<>(), new ArrayList<>(), temperatures, highTemperatures);
List<ListenableFuture<List<Void>>> attributeFutures = new ArrayList<>();
for (int i = 0; i < devices.size(); i++) {
@ -321,6 +346,8 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
filter.setRootEntity(tenantId);
filter.setDirection(EntitySearchDirection.FROM);
filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Contains", Collections.singletonList(EntityType.DEVICE))));
filter.setMaxLevel(maxLevel);
filter.setFetchLastLevelOnly(fetchLastLevelOnly);
EntityDataSortOrder sortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC
@ -373,14 +400,13 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
deviceService.deleteDevicesByTenantId(tenantId);
}
@Test
public void testHierarchicalFindDevicesWithAttributesByQuery() throws ExecutionException, InterruptedException {
List<Asset> assets = new ArrayList<>();
List<Device> devices = new ArrayList<>();
List<Long> temperatures = new ArrayList<>();
List<Long> highTemperatures = new ArrayList<>();
createTestHierarchy(assets, devices, new ArrayList<>(), new ArrayList<>(), temperatures, highTemperatures);
createTestHierarchy(tenantId, assets, devices, new ArrayList<>(), new ArrayList<>(), temperatures, highTemperatures);
List<ListenableFuture<List<Void>>> attributeFutures = new ArrayList<>();
for (int i = 0; i < devices.size(); i++) {
@ -393,6 +419,8 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
filter.setRootEntity(tenantId);
filter.setDirection(EntitySearchDirection.FROM);
filter.setRelationType("Contains");
filter.setMaxLevel(2);
filter.setFetchLastLevelOnly(true);
EntityDataSortOrder sortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC
@ -446,14 +474,14 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
deviceService.deleteDevicesByTenantId(tenantId);
}
@Test
public void testHierarchicalFindAssetsWithAttributesByQuery() throws ExecutionException, InterruptedException {
List<Asset> assets = new ArrayList<>();
List<Device> devices = new ArrayList<>();
List<Long> consumptions = new ArrayList<>();
List<Long> highConsumptions = new ArrayList<>();
createTestHierarchy(assets, devices, consumptions, highConsumptions, new ArrayList<>(), new ArrayList<>());
createTestHierarchy(tenantId, assets, devices, consumptions, highConsumptions, new ArrayList<>(), new ArrayList<>());
List<ListenableFuture<List<Void>>> attributeFutures = new ArrayList<>();
for (int i = 0; i < assets.size(); i++) {
@ -518,8 +546,8 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
deviceService.deleteDevicesByTenantId(tenantId);
}
private void createTestHierarchy(List<Asset> assets, List<Device> devices, List<Long> consumptions, List<Long> highConsumptions, List<Long> temperatures, List<Long> highTemperatures) throws InterruptedException {
for (int i = 0; i < 5; i++) {
private void createTestHierarchy(TenantId tenantId, List<Asset> assets, List<Device> devices, List<Long> consumptions, List<Long> highConsumptions, List<Long> temperatures, List<Long> highTemperatures) throws InterruptedException {
for (int i = 0; i < ENTITY_COUNT; i++) {
Asset asset = new Asset();
asset.setTenantId(tenantId);
asset.setName("Asset" + i);
@ -529,18 +557,19 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
//TO make sure devices have different created time
Thread.sleep(1);
assets.add(asset);
EntityRelation er = new EntityRelation();
er.setFrom(tenantId);
er.setTo(asset.getId());
er.setType("Manages");
er.setTypeGroup(RelationTypeGroup.COMMON);
relationService.saveRelation(tenantId, er);
createRelation(tenantId, "Manages", tenantId, asset.getId());
long consumption = (long) (Math.random() * 100);
consumptions.add(consumption);
if (consumption > 50) {
highConsumptions.add(consumption);
}
for (int j = 0; j < 5; j++) {
//tenant -> asset : one-to-one but many edges
for (int n = 0; n < ENTITY_COUNT; n++) {
createRelation(tenantId, "UseCase-" + n, tenantId, asset.getId());
}
for (int j = 0; j < ENTITY_COUNT; j++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("A" + i + "Device" + j);
@ -550,22 +579,125 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
//TO make sure devices have different created time
Thread.sleep(1);
devices.add(device);
er = new EntityRelation();
er.setFrom(asset.getId());
er.setTo(device.getId());
er.setType("Contains");
er.setTypeGroup(RelationTypeGroup.COMMON);
relationService.saveRelation(tenantId, er);
createRelation(tenantId, "Contains", asset.getId(), device.getId());
long temperature = (long) (Math.random() * 100);
temperatures.add(temperature);
if (temperature > 45) {
highTemperatures.add(temperature);
}
//asset -> device : one-to-one but many edges
for (int n = 0; n < ENTITY_COUNT; n++) {
createRelation(tenantId, "UseCase-" + n, asset.getId(), device.getId());
}
}
}
//asset -> device one-to-many shared with other assets
for (int n = 0; n < devices.size(); n = n + ENTITY_COUNT) {
createRelation(tenantId, "SharedWithAsset0", assets.get(0).getId(), devices.get(n).getId());
}
createManyCustomRelationsBetweenTwoNodes(tenantId, "UseCase", assets, devices);
createHorizontalRingRelations(tenantId, "Ring(Loop)-Ast", assets);
createLoopRelations(tenantId, "Loop-Tnt-Ast-Dev", tenantId, assets.get(0).getId(), devices.get(0).getId());
createLoopRelations(tenantId, "Loop-Tnt-Ast", tenantId, assets.get(1).getId());
createLoopRelations(tenantId, "Loop-Ast-Tnt-Ast", assets.get(2).getId(), tenantId, assets.get(3).getId());
//printAllRelations();
}
private ResultSetExtractor<List<List<String>>> getListResultSetExtractor() {
return rs -> {
List<List<String>> list = new ArrayList<>();
final int columnCount = rs.getMetaData().getColumnCount();
List<String> columns = new ArrayList<>(columnCount);
for (int i = 1; i <= columnCount; i++) {
columns.add(rs.getMetaData().getColumnName(i));
}
list.add(columns);
while (rs.next()) {
List<String> data = new ArrayList<>(columnCount);
for (int i = 1; i <= columnCount; i++) {
data.add(rs.getString(i));
}
list.add(data);
}
return list;
};
}
/*
* This useful to reproduce exact data in the PostgreSQL and play around with pgadmin query and analyze tool
* */
private void printAllRelations() {
System.out.println("" +
"DO\n" +
"$$\n" +
" DECLARE\n" +
" someint integer;\n" +
" BEGIN\n" +
" DROP TABLE IF EXISTS relation_test;\n" +
" CREATE TABLE IF NOT EXISTS relation_test\n" +
" (\n" +
" from_id uuid,\n" +
" from_type varchar(255),\n" +
" to_id uuid,\n" +
" to_type varchar(255),\n" +
" relation_type_group varchar(255),\n" +
" relation_type varchar(255),\n" +
" additional_info varchar,\n" +
" CONSTRAINT relation_test_pkey PRIMARY KEY (from_id, from_type, relation_type_group, relation_type, to_id, to_type)\n" +
" );");
relationRepository.findAll().forEach(r ->
System.out.printf("INSERT INTO relation_test (from_id, from_type, to_id, to_type, relation_type_group, relation_type, additional_info)" +
" VALUES (%s, %s, %s, %s, %s, %s, %s);\n",
quote(r.getFromId()), quote(r.getFromType()), quote(r.getToId()), quote(r.getToType()),
quote(r.getRelationTypeGroup()), quote(r.getRelationType()), quote(r.getAdditionalInfo()))
);
System.out.println("" +
" END\n" +
"$$;");
}
private String quote(Object s) {
return s == null ? null : "'" + s + "'";
}
void createLoopRelations(TenantId tenantId, String type, EntityId... ids) {
assertThat("ids lenght", ids.length, Matchers.greaterThanOrEqualTo(1));
//chain all from the head to the tail
for (int i = 1; i < ids.length; i++) {
relationService.saveRelation(tenantId, new EntityRelation(ids[i - 1], ids[i], type, RelationTypeGroup.COMMON));
}
//chain tail -> head
relationService.saveRelation(tenantId, new EntityRelation(ids[ids.length - 1], ids[0], type, RelationTypeGroup.COMMON));
}
void createHorizontalRingRelations(TenantId tenantId, String type, List<Asset> assets) {
createLoopRelations(tenantId, type, assets.stream().map(Asset::getId).toArray(EntityId[]::new));
}
void createManyCustomRelationsBetweenTwoNodes(TenantId tenantId, String type, List<Asset> assets, List<Device> devices) {
for (int i = 1; i <= 5; i++) {
final String typeI = type + i;
createOneToManyRelations(tenantId, typeI, tenantId, assets.stream().map(Asset::getId).collect(Collectors.toList()));
assets.forEach(asset ->
createOneToManyRelations(tenantId, typeI, asset.getId(), devices.stream().map(Device::getId).collect(Collectors.toList())));
}
}
void createOneToManyRelations(TenantId tenantId, String type, EntityId from, List<EntityId> toIds) {
toIds.forEach(toId -> createRelation(tenantId, type, from, toId));
}
void createRelation(TenantId tenantId, String type, EntityId from, EntityId toId) {
relationService.saveRelation(tenantId, new EntityRelation(from, toId, type, RelationTypeGroup.COMMON));
}
@Test
public void testSimpleFindEntityDataByQuery() throws InterruptedException {
List<Device> devices = new ArrayList<>();
@ -871,7 +1003,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
}
@Test
public void testBuildNumericPredicateQueryOperations() throws ExecutionException, InterruptedException{
public void testBuildNumericPredicateQueryOperations() throws ExecutionException, InterruptedException {
List<Device> devices = new ArrayList<>();
List<Long> temperatures = new ArrayList<>();
@ -1031,7 +1163,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
deviceService.deleteDevicesByTenantId(tenantId);
}
@Test
public void testFindEntityDataByQueryWithTimeseries() throws ExecutionException, InterruptedException {
@ -1122,7 +1254,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
}
@Test
public void testBuildStringPredicateQueryOperations() throws ExecutionException, InterruptedException{
public void testBuildStringPredicateQueryOperations() throws ExecutionException, InterruptedException {
List<Device> devices = new ArrayList<>();
List<String> attributeStrings = new ArrayList<>();
@ -1142,11 +1274,11 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
devices.add(deviceService.saveDevice(device));
//TO make sure devices have different created time
Thread.sleep(1);
List<StringFilterPredicate.StringOperation> operationValues= Arrays.asList(StringFilterPredicate.StringOperation.values());
List<StringFilterPredicate.StringOperation> operationValues = Arrays.asList(StringFilterPredicate.StringOperation.values());
StringFilterPredicate.StringOperation operation = operationValues.get(new Random().nextInt(operationValues.size()));
String operationName = operation.name();
attributeStrings.add(operationName);
switch(operation){
switch (operation) {
case EQUAL:
equalStrings.add(operationName);
notContainsStrings.add(operationName);
@ -1302,7 +1434,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
}
@Test
public void testBuildStringPredicateQueryOperationsForEntityType() throws ExecutionException, InterruptedException{
public void testBuildStringPredicateQueryOperationsForEntityType() throws ExecutionException, InterruptedException {
List<Device> devices = new ArrayList<>();
@ -1419,7 +1551,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
}
@Test
public void testBuildSimplePredicateQueryOperations() throws InterruptedException{
public void testBuildSimplePredicateQueryOperations() throws InterruptedException {
List<Device> devices = new ArrayList<>();
@ -1492,7 +1624,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
return loadedEntities;
}
private List<KeyFilter> createStringKeyFilters(String key, EntityKeyType keyType, StringFilterPredicate.StringOperation operation, String value){
private List<KeyFilter> createStringKeyFilters(String key, EntityKeyType keyType, StringFilterPredicate.StringOperation operation, String value) {
KeyFilter filter = new KeyFilter();
filter.setKey(new EntityKey(keyType, key));
StringFilterPredicate predicate = new StringFilterPredicate();
@ -1503,7 +1635,7 @@ public abstract class BaseEntityServiceTest extends AbstractServiceTest {
return Collections.singletonList(filter);
}
private KeyFilter createNumericKeyFilter(String key, EntityKeyType keyType, NumericFilterPredicate.NumericOperation operation, double value){
private KeyFilter createNumericKeyFilter(String key, EntityKeyType keyType, NumericFilterPredicate.NumericOperation operation, double value) {
KeyFilter filter = new KeyFilter();
filter.setKey(new EntityKey(keyType, key));
NumericFilterPredicate predicate = new NumericFilterPredicate();

2
dao/src/test/java/org/thingsboard/server/dao/service/BaseOAuth2ConfigTemplateServiceTest.java

@ -31,7 +31,7 @@ import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService;
import java.util.Arrays;
import java.util.UUID;
public class BaseOAuth2ConfigTemplateServiceTest extends AbstractServiceTest {
public abstract class BaseOAuth2ConfigTemplateServiceTest extends AbstractServiceTest {
@Autowired
protected OAuth2ConfigTemplateService oAuth2ConfigTemplateService;

2
dao/src/test/java/org/thingsboard/server/dao/service/BaseOAuth2ServiceTest.java

@ -43,7 +43,7 @@ import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
public class BaseOAuth2ServiceTest extends AbstractServiceTest {
public abstract class BaseOAuth2ServiceTest extends AbstractServiceTest {
private static final OAuth2Info EMPTY_PARAMS = new OAuth2Info(false, Collections.emptyList());
@Autowired

2
dao/src/test/java/org/thingsboard/server/dao/service/BaseTenantProfileServiceTest.java

@ -34,7 +34,7 @@ import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class BaseTenantProfileServiceTest extends AbstractServiceTest {
public abstract class BaseTenantProfileServiceTest extends AbstractServiceTest {
private IdComparator<TenantProfile> idComparator = new IdComparator<>();
private IdComparator<EntityInfo> tenantProfileInfoIdComparator = new IdComparator<>();

33
dao/src/test/java/org/thingsboard/server/dao/service/DaoPostgreSqlTest.java

@ -0,0 +1,33 @@
/**
* Copyright © 2016-2021 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.service;
import org.springframework.test.context.TestPropertySource;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@TestPropertySource(locations = {"classpath:application-test.properties", "classpath:psql-test.properties"})
public @interface DaoPostgreSqlTest {
}

8
dao/src/test/java/org/thingsboard/server/dao/service/sql/EntityServiceSqlTest.java → dao/src/test/java/org/thingsboard/server/dao/service/psql/EntityServicePostgreSqlTest.java

@ -13,11 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.service.sql;
package org.thingsboard.server.dao.service.psql;
import org.thingsboard.server.dao.service.BaseEntityServiceTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.service.DaoPostgreSqlTest;
@DaoSqlTest
public class EntityServiceSqlTest extends BaseEntityServiceTest {
@DaoPostgreSqlTest
public class EntityServicePostgreSqlTest extends BaseEntityServiceTest {
}

69
dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepositoryTest.java

@ -0,0 +1,69 @@
/**
* Copyright © 2016-2021 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.sql.query;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.support.TransactionTemplate;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = DefaultEntityQueryRepository.class)
public class DefaultEntityQueryRepositoryTest {
@MockBean
NamedParameterJdbcTemplate jdbcTemplate;
@MockBean
TransactionTemplate transactionTemplate;
@MockBean
DefaultQueryLogComponent queryLog;
@Autowired
DefaultEntityQueryRepository repo;
/*
* This value has to be reasonable small to prevent infinite recursion as early as possible
* */
@Test
public void givenDefaultMaxLevel_whenStaticConstant_thenEqualsTo() {
assertThat(repo.getMaxLevelAllowed(), equalTo(50));
}
@Test
public void givenMaxLevelZeroOrNegative_whenGetMaxLevel_thenReturnDefaultMaxLevel() {
assertThat(repo.getMaxLevel(0), equalTo(repo.getMaxLevelAllowed()));
assertThat(repo.getMaxLevel(-1), equalTo(repo.getMaxLevelAllowed()));
assertThat(repo.getMaxLevel(-2), equalTo(repo.getMaxLevelAllowed()));
assertThat(repo.getMaxLevel(Integer.MIN_VALUE), equalTo(repo.getMaxLevelAllowed()));
}
@Test
public void givenMaxLevelPositive_whenGetMaxLevel_thenValueTheSame() {
assertThat(repo.getMaxLevel(1), equalTo(1));
assertThat(repo.getMaxLevel(2), equalTo(2));
assertThat(repo.getMaxLevel(repo.getMaxLevelAllowed()), equalTo(repo.getMaxLevelAllowed()));
assertThat(repo.getMaxLevel(repo.getMaxLevelAllowed() + 1), equalTo(repo.getMaxLevelAllowed()));
assertThat(repo.getMaxLevel(Integer.MAX_VALUE), equalTo(repo.getMaxLevelAllowed()));
}
}

47
dao/src/test/resources/psql-test.properties

@ -0,0 +1,47 @@
database.ts.type=sql
database.ts_latest.type=sql
sql.ts_inserts_executor_type=fixed
sql.ts_inserts_fixed_thread_pool_size=200
sql.ts_key_value_partitioning=MONTHS
#
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
spring.jpa.properties.hibernate.order_by.default_null_ordering=last
spring.jpa.properties.hibernate.jdbc.log.warnings=false
spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
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.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
#org.postgresql.Driver
spring.datasource.hikari.maximumPoolSize=50
service.type=monolith
#database.ts.type=timescale
#database.ts.type=sql
#database.entities.type=sql
#
#sql.ts_inserts_executor_type=fixed
#sql.ts_inserts_fixed_thread_pool_size=200
#sql.ts_key_value_partitioning=MONTHS
#
#spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
#spring.jpa.show-sql=false
#spring.jpa.hibernate.ddl-auto=none
#spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
#
#spring.datasource.username=postgres
#spring.datasource.password=postgres
#spring.datasource.url=jdbc:postgresql://localhost:5432/sqltest
#spring.datasource.driverClassName=org.postgresql.Driver
#spring.datasource.hikari.maximumPoolSize = 50
queue.core.pack-processing-timeout=3000
queue.rule-engine.pack-processing-timeout=3000
queue.rule-engine.queues[0].name=Main
queue.rule-engine.queues[0].topic=tb_rule_engine.main
queue.rule-engine.queues[0].poll-interval=25
queue.rule-engine.queues[0].partitions=3
queue.rule-engine.queues[0].pack-processing-timeout=3000
queue.rule-engine.queues[0].processing-strategy.type=SKIP_ALL_FAILURES
queue.rule-engine.queues[0].submit-strategy.type=BURST
sql.log_entity_queries=true

2
dao/src/test/resources/sql/system-test-psql.sql

@ -0,0 +1,2 @@
--PostgreSQL specific truncate to fit constraints
TRUNCATE TABLE device_credentials, device, device_profile, rule_node_state, rule_node, rule_chain;

11
pom.xml

@ -1643,6 +1643,17 @@
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<version>${hsqldb.version}</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>jdbc</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>

12
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractAlarmNodeConfiguration.java

@ -20,6 +20,18 @@ import lombok.Data;
@Data
public abstract class TbAbstractAlarmNodeConfiguration {
static final String ALARM_DETAILS_BUILD_JS_TEMPLATE = "" +
"var details = {};\n" +
"if (metadata.prevAlarmDetails) {\n" +
" details = JSON.parse(metadata.prevAlarmDetails);\n" +
" //remove prevAlarmDetails from metadata\n" +
" delete metadata.prevAlarmDetails;\n" +
" //now metadata is the same as it comes IN this rule node\n" +
"}\n" +
"\n" +
"\n" +
"return details;";
private String alarmType;
private String alarmDetailsBuildJs;

6
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeConfiguration.java

@ -25,11 +25,7 @@ public class TbClearAlarmNodeConfiguration extends TbAbstractAlarmNodeConfigurat
@Override
public TbClearAlarmNodeConfiguration defaultConfiguration() {
TbClearAlarmNodeConfiguration configuration = new TbClearAlarmNodeConfiguration();
configuration.setAlarmDetailsBuildJs("var details = {};\n" +
"if (metadata.prevAlarmDetails) {\n" +
" details = JSON.parse(metadata.prevAlarmDetails);\n" +
"}\n" +
"return details;");
configuration.setAlarmDetailsBuildJs(ALARM_DETAILS_BUILD_JS_TEMPLATE);
configuration.setAlarmType("General Alarm");
return configuration;
}

6
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeConfiguration.java

@ -35,11 +35,7 @@ public class TbCreateAlarmNodeConfiguration extends TbAbstractAlarmNodeConfigura
@Override
public TbCreateAlarmNodeConfiguration defaultConfiguration() {
TbCreateAlarmNodeConfiguration configuration = new TbCreateAlarmNodeConfiguration();
configuration.setAlarmDetailsBuildJs("var details = {};\n" +
"if (metadata.prevAlarmDetails) {\n" +
" details = JSON.parse(metadata.prevAlarmDetails);\n" +
"}\n" +
"return details;");
configuration.setAlarmDetailsBuildJs(ALARM_DETAILS_BUILD_JS_TEMPLATE);
configuration.setAlarmType("General Alarm");
configuration.setSeverity(AlarmSeverity.CRITICAL.name());
configuration.setPropagate(false);

68
rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/clear_alarm_node_script_fn.md

@ -0,0 +1,68 @@
#### Clear alarm details builder function
<div class="divider"></div>
<br/>
*function Details(msg, metadata, msgType): any*
JavaScript function generating **Alarm Details** object to update existing one. Used for storing additional parameters inside Alarm.<br>
For example you can save attribute name/value pair from Original Message payload or Metadata.
**Parameters:**
{% include rulenode/common_node_script_args %}
**Returns:**
Should return the object presenting **Alarm Details**.
Current Alarm Details can be accessed via `metadata.prevAlarmDetails`.<br>
**Note** that `metadata.prevAlarmDetails` is a raw String field, and it needs to be converted into object using this construction:
```javascript
var details = {};
if (metadata.prevAlarmDetails) {
// remove prevAlarmDetails from metadata
delete metadata.prevAlarmDetails;
details = JSON.parse(metadata.prevAlarmDetails);
}
{:copy-code}
```
<div class="divider"></div>
##### Examples
<ul>
<li>
Take <code>count</code> property from previous Alarm and increment it.<br>
Also put <code>temperature</code> attribute from inbound Message payload into Alarm details:
</li>
</ul>
```javascript
var details = {temperature: msg.temperature, count: 1};
if (metadata.prevAlarmDetails) {
var prevDetails = JSON.parse(metadata.prevAlarmDetails);
// remove prevAlarmDetails from metadata
delete metadata.prevAlarmDetails;
if (prevDetails.count) {
details.count = prevDetails.count + 1;
}
}
return details;
{:copy-code}
```
<br>
More details about Alarms can be found in [this tutorial{:target="_blank"}](${baseUrl}/docs/user-guide/alarms/).
You can see the real life example, where this node is used, in the next tutorial:
- [Create and Clear Alarms{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/)
<br>
<br>

8
rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/common_node_script_args.md

@ -0,0 +1,8 @@
<ul>
<li><b>msg:</b> <code>{[key: string]: any}</code> - is a Message payload key/value object.
</li>
<li><b>metadata:</b> <code>{[key: string]: string}</code> - is a Message metadata key/value object.
</li>
<li><b>msgType:</b> <code>string</code> - is a string Message type. See <a href="https://github.com/thingsboard/thingsboard/blob/ea039008b148453dfa166cf92bc40b26e487e660/ui-ngx/src/app/shared/models/rule-node.models.ts#L338" target="_blank">MessageType</a> enum for common used values.
</li>
</ul>

69
rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/create_alarm_node_script_fn.md

@ -0,0 +1,69 @@
#### Create alarm details builder function
<div class="divider"></div>
<br/>
*function Details(msg, metadata, msgType): any*
JavaScript function generating **Alarm Details** object. Used for storing additional parameters inside Alarm.<br>
For example you can save attribute name/value pair from Original Message payload or Metadata.
**Parameters:**
{% include rulenode/common_node_script_args %}
**Returns:**
Should return the object presenting **Alarm Details**.
**Optional:** previous Alarm Details can be accessed via `metadata.prevAlarmDetails`.<br>
If previous Alarm does not exist, this field will not be present in Metadata. **Note** that `metadata.prevAlarmDetails`<br>
is a raw String field, and it needs to be converted into object using this construction:
```javascript
var details = {};
if (metadata.prevAlarmDetails) {
// remove prevAlarmDetails from metadata
delete metadata.prevAlarmDetails;
details = JSON.parse(metadata.prevAlarmDetails);
}
{:copy-code}
```
<div class="divider"></div>
##### Examples
<ul>
<li>
Take <code>count</code> property from previous Alarm and increment it.<br>
Also put <code>temperature</code> attribute from inbound Message payload into Alarm details:
</li>
</ul>
```javascript
var details = {temperature: msg.temperature, count: 1};
if (metadata.prevAlarmDetails) {
var prevDetails = JSON.parse(metadata.prevAlarmDetails);
// remove prevAlarmDetails from metadata
delete metadata.prevAlarmDetails;
if (prevDetails.count) {
details.count = prevDetails.count + 1;
}
}
return details;
{:copy-code}
```
<br>
More details about Alarms can be found in [this tutorial{:target="_blank"}](${baseUrl}/docs/user-guide/alarms/).
You can see the real life example, where this node is used, in the next tutorial:
- [Create and Clear Alarms{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/)
<br>
<br>

69
rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/filter_node_script_fn.md

@ -0,0 +1,69 @@
#### Filter message function
<div class="divider"></div>
<br/>
*function Filter(msg, metadata, msgType): boolean*
JavaScript function evaluating **true/false** condition on incoming Message.
**Parameters:**
{% include rulenode/common_node_script_args %}
**Returns:**
Should return `boolean` value. If `true` - send Message via **True** chain, otherwise **False** chain is used.
<div class="divider"></div>
##### Examples
* Forward all messages with `temperature` value greater than `20` to the **True** chain and all other messages to the **False** chain:
```javascript
return msg.temperature > 20;
{:copy-code}
```
* Forward all messages with type `ATTRIBUTES_UPDATED` to the **True** chain and all other messages to the **False** chain:
```javascript
if (msgType === 'ATTRIBUTES_UPDATED') {
return true;
} else {
return false;
}
{:copy-code}
```
<ul>
<li>Send message to the <strong>True</strong> chain if the following conditions are met.<br>Message type is <code>POST_TELEMETRY_REQUEST</code> and<br>
(device type is <code>vehicle</code> and <code>humidity</code> value is greater than <code>50</code> or<br>
device type is <code>controller</code> and <code>temperature</code> value is greater than <code>20</code> and <code>humidity</code> value is greater than <code>60</code>).<br>
Otherwise send message to the <strong>False</strong> chain:
</li>
</ul>
```javascript
if (msgType === 'POST_TELEMETRY_REQUEST') {
if (metadata.deviceType === 'vehicle') {
return msg.humidity > 50;
} else if (metadata.deviceType === 'controller') {
return msg.temperature > 20 && msg.humidity > 60;
}
}
return false;
{:copy-code}
```
<br>
You can see real life example, how to use this node in those tutorials:
- [Create and Clear Alarms{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/#node-a-filter-script)
- [Reply to RPC Calls{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-filter-script-node)
<br>
<br>

118
rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/generator_node_script_fn.md

@ -0,0 +1,118 @@
#### Message generator function
<div class="divider"></div>
<br/>
*function Generate(prevMsg, prevMetadata, prevMsgType): {msg: object, metadata: object, msgType: string}*
JavaScript function generating new message using previous Message payload, Metadata and Message type as input arguments.
**Parameters:**
<ul>
<li><b>prevMsg:</b> <code>{[key: string]: any}</code> - is a previously generated Message payload key/value object.
</li>
<li><b>prevMetadata:</b> <code>{[key: string]: string}</code> - is a previously generated Message metadata key/value object.
</li>
<li><b>prevMsgType:</b> <code>string</code> - is a previously generated string Message type. See <a href="https://github.com/thingsboard/thingsboard/blob/ea039008b148453dfa166cf92bc40b26e487e660/ui-ngx/src/app/shared/models/rule-node.models.ts#L338" target="_blank">MessageType</a> enum for common used values.
</li>
</ul>
**Returns:**
Should return the object with the following structure:
```javascript
{
msg?: {[key: string]: any},
metadata?: {[key: string]: string},
msgType?: string
}
```
All fields in resulting object are optional and will be taken from previously generated Message if not specified.
<div class="divider"></div>
##### Examples
* Generate message of type `POST_TELEMETRY_REQUEST` with random `temperature` value from `18` to `32`:
```javascript
var temperature = 18 + Math.random() * 14;
// Round to at most 2 decimal places (optional)
temperature = Math.round( temperature * 100 ) / 100;
var msg = { temperature: temperature };
var metadata = {};
var msgType = "POST_TELEMETRY_REQUEST";
return { msg: msg, metadata: metadata, msgType: msgType };
{:copy-code}
```
<ul>
<li>
Generate message of type <code>POST_TELEMETRY_REQUEST</code> with <code>temp</code> value <code>42</code>,
<code>humidity</code> value <code>77</code><br>
and <strong>metadata</strong> with field <code>data</code> having value <code>40</code>:
</li>
</ul>
```javascript
var msg = { temp: 42, humidity: 77 };
var metadata = { data: 40 };
var msgType = "POST_TELEMETRY_REQUEST";
return { msg: msg, metadata: metadata, msgType: msgType };
{:copy-code}
```
<ul>
<li>
Generate message of type <code>POST_TELEMETRY_REQUEST</code> with <code>temperature</code> value<br>
increasing and decreasing linearly in the range from <code>18</code> to <code>32</code>:
</li>
</ul>
```javascript
var lower = 18;
var upper = 32;
var isDecrement = 'false';
var temperature = lower;
// Get previous values
if (typeof prevMetadata !== 'undefined' &&
typeof prevMetadata.isDecrement !== 'undefined') {
isDecrement = prevMetadata.isDecrement;
}
if (typeof prevMsg !== 'undefined' &&
typeof prevMsg.temperature !== 'undefined') {
temperature = prevMsg.temperature;
}
if (isDecrement === 'true') {
temperature--;
if (temperature <= lower) {
isDecrement = 'false';
temperature = lower;
}
} else {
temperature++;
if (temperature >= upper) {
isDecrement = 'true';
temperature = upper;
}
}
var msg = { temperature: temperature };
var metadata = { isDecrement: isDecrement };
var msgType = "POST_TELEMETRY_REQUEST";
return { msg: msg, metadata: metadata, msgType: msgType };
{:copy-code}
```
<br>
<br>

37
rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/log_node_script_fn.md

@ -0,0 +1,37 @@
#### Message to string function
<div class="divider"></div>
<br/>
*function toString(msg, metadata, msgType): string*
JavaScript function transforming incoming Message to String for further logging to the server log file.
**Parameters:**
{% include rulenode/common_node_script_args %}
**Returns:**
Should return `string` value used for logging to the server log file.
<div class="divider"></div>
##### Examples
* Create string message containing incoming message and incoming metadata values:
```javascript
return 'Incoming message:\n' + JSON.stringify(msg) +
'\nIncoming metadata:\n' + JSON.stringify(metadata);
{:copy-code}
```
<br>
You can see real life example, how to use this node in this tutorial:
- [Reply to RPC Calls{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#log-unknown-request)
<br>
<br>

96
rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/switch_node_script_fn.md

@ -0,0 +1,96 @@
#### Switch message function
<div class="divider"></div>
<br/>
*function Switch(msg, metadata, msgType): string[]*
JavaScript function computing **an array of next Relation names** for incoming Message.
**Parameters:**
{% include rulenode/common_node_script_args %}
**Returns:**
Should return an array of `string` values presenting **next Relation names** where Message should be routed.<br>
If returned array is empty - message will not be routed to any Node and discarded.
<div class="divider"></div>
##### Examples
<ul>
<li>
Forward all messages with <code>temperature</code> value greater than <code>30</code> to the <strong>'High temperature'</strong> chain,<br>
with <code>temperature</code> value lower than <code>20</code> to the <strong>'Low temperature'</strong> chain and all other messages<br>
to the <strong>'Normal temperature'</strong> chain:
</li>
</ul>
```javascript
if (msg.temperature > 30) {
return ['High temperature'];
} else if (msg.temperature < 20) {
return ['Low temperature'];
} else {
return ['Normal temperature'];
}
{:copy-code}
```
<ul>
<li>
For messages with type <code>POST_TELEMETRY_REQUEST</code>:
<ul>
<li>
if <code>temperature</code> value lower than <code>18</code> forward to the <strong>'Low temperature telemetry'</strong> chain,
</li>
<li>
otherwise to the <strong>'Normal temperature telemetry'</strong> chain.
</li>
</ul>
For messages with type <code>POST_ATTRIBUTES_REQUEST</code>:<br>
<ul>
<li>
if <code>currentState</code> value is <code>IDLE</code> forward to the <strong>'Idle State'</strong> and <strong>'Update State Attribute'</strong> chains,
</li>
<li>
if <code>currentState</code> value is <code>RUNNING</code> forward to the <strong>'Running State'</strong> and <strong>'Update State Attribute'</strong> chains,
</li>
<li>
otherwise to the <strong>'Unknown State'</strong> chain.
</li>
</ul>
For all other message types - discard the message (do not route to any Node).
</li>
</ul>
```javascript
if (msgType === 'POST_TELEMETRY_REQUEST') {
if (msg.temperature < 18) {
return ['Low Temperature Telemetry'];
} else {
return ['Normal Temperature Telemetry'];
}
} else if (msgType === 'POST_ATTRIBUTES_REQUEST') {
if (msg.currentState === 'IDLE') {
return ['Idle State', 'Update State Attribute'];
} else if (msg.currentState === 'RUNNING') {
return ['Running State', 'Update State Attribute'];
} else {
return ['Unknown State'];
}
}
return [];
{:copy-code}
```
<br>
You can see real life example, how to use this node in this tutorial:
- [Data function based on telemetry from 2 devices{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/function-based-on-telemetry-from-two-devices#delta-temperature-rule-chain)
<br>
<br>

22
rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/transformation_node_script_fn.md

@ -3,20 +3,13 @@
<div class="divider"></div>
<br/>
*function (msg, metadata msgType): {msg: object, metadata: object, msgType: string}*
*function Transform(msg, metadata, msgType): {msg: object, metadata: object, msgType: string}*
JavaScript function transforming input Message payload, Metadata or Message type.
**Parameters:**
<ul>
<li><b>msg:</b> <code>{[key: string]: any}</code> - is a Message payload key/value object.
</li>
<li><b>metadata:</b> <code>{[key: string]: string}</code> - is a Message metadata key/value object.
</li>
<li><b>msgType:</b> <code>string</code> - is a string Message type. See <a href="https://github.com/thingsboard/thingsboard/blob/ea039008b148453dfa166cf92bc40b26e487e660/ui-ngx/src/app/shared/models/rule-node.models.ts#L338" target="_blank">MessageType</a> enum for common used values.
</li>
</ul>
{% include rulenode/common_node_script_args %}
**Returns:**
@ -47,8 +40,6 @@ return { msgType: 'CUSTOM_REQUEST' };
<li>Change message type to <code>CUSTOM_UPDATE</code>,<br/>add additional attribute <strong><em>version</em></strong> into payload with value <strong><em>v1.1</em></strong>,<br/>change <strong><em>sensorType</em></strong> attribute value in Metadata to <strong><em>roomTemp</em></strong>:</li>
</ul>
The following transform function will perform all necessary modifications:
```javascript
var newType = "CUSTOM_UPDATE";
msg.version = "v1.1";
@ -57,9 +48,12 @@ return {msg: msg, metadata: metadata, msgType: newType};
{:copy-code}
```
You can see real life example, how to use this node in those tutorials:
<br>
- [Transform incoming telemetry{:target="_blank"}](https://thingsboard.io/docs/user-guide/rule-engine-2-0/tutorials/transform-incoming-telemetry/)
- [Reply to RPC Calls{:target="_blank"}](https://thingsboard.io/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-transform-script-node)
You can see real life example, how to use this node in those tutorials:
- [Transform incoming telemetry{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/transform-incoming-telemetry/)
- [Reply to RPC Calls{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-transform-script-node)
<br>
<br>

2
rule-engine/rule-engine-components/src/main/resources/public/static/rulenode/rulenode-core-config.js

File diff suppressed because one or more lines are too long

1
ui-ngx/angular.json

@ -97,6 +97,7 @@
"node_modules/prismjs/components/prism-bash.min.js",
"node_modules/prismjs/components/prism-json.min.js",
"node_modules/prismjs/components/prism-javascript.min.js",
"node_modules/prismjs/components/prism-typescript.min.js",
"node_modules/prismjs/plugins/line-numbers/prism-line-numbers.js"
],
"customWebpackConfig": {

2
ui-ngx/package.json

@ -73,7 +73,7 @@
"ngx-drag-drop": "^2.0.0",
"ngx-flowchart": "git://github.com/thingsboard/ngx-flowchart.git#master",
"ngx-hm-carousel": "^2.0.0-rc.1",
"ngx-markdown": "^10.1.1",
"ngx-markdown": "^11.1.3",
"ngx-sharebuttons": "^8.0.5",
"ngx-translate-messageformat-compiler": "^4.9.0",
"objectpath": "^2.0.0",

66
ui-ngx/src/app/core/services/dynamic-component-factory.service.ts

@ -23,10 +23,12 @@ import {
NgModule,
NgModuleRef,
OnDestroy,
Type
Type,
ɵresetCompiledComponents
} from '@angular/core';
import { Observable, ReplaySubject } from 'rxjs';
import { from, Observable } from 'rxjs';
import { CommonModule } from '@angular/common';
import { catchError, map, mergeMap } from 'rxjs/operators';
@NgModule()
export abstract class DynamicComponentModule implements OnDestroy {
@ -55,11 +57,12 @@ export class DynamicComponentFactoryService {
public createDynamicComponentFactory<T>(
componentType: Type<T>,
template: string,
modules?: Type<any>[]): Observable<ComponentFactory<T>> {
const dymamicComponentFactorySubject = new ReplaySubject<ComponentFactory<T>>();
import('@angular/compiler').then(
() => {
const comp = this.createDynamicComponent(componentType, template);
modules?: Type<any>[],
preserveWhitespaces?: boolean,
compileAttempt = 1): Observable<ComponentFactory<T>> {
return from(import('@angular/compiler')).pipe(
mergeMap(() => {
const comp = this.createDynamicComponent(componentType, template, preserveWhitespaces);
let moduleImports: Type<any>[] = [CommonModule];
if (modules) {
moduleImports = [...moduleImports, ...modules];
@ -69,29 +72,33 @@ export class DynamicComponentFactoryService {
declarations: [comp],
imports: moduleImports
})(class DynamicComponentInstanceModule extends DynamicComponentModule {});
try {
this.compiler.compileModuleAsync(dynamicComponentInstanceModule).then(
(module) => {
const moduleRef = module.create(this.injector);
const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(comp);
this.dynamicComponentModulesMap.set(factory, {
moduleRef,
moduleType: module.moduleType
});
dymamicComponentFactorySubject.next(factory);
dymamicComponentFactorySubject.complete();
return from(this.compiler.compileModuleAsync(dynamicComponentInstanceModule)).pipe(
map((module) => {
let moduleRef: NgModuleRef<any>;
try {
moduleRef = module.create(this.injector);
} catch (e) {
this.compiler.clearCacheFor(module.moduleType);
throw e;
}
).catch(
(e) => {
dymamicComponentFactorySubject.error(e);
const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(comp);
this.dynamicComponentModulesMap.set(factory, {
moduleRef,
moduleType: module.moduleType
});
return factory;
}),
catchError((error) => {
if (compileAttempt === 1) {
ɵresetCompiledComponents();
return this.createDynamicComponentFactory(componentType, template, modules, preserveWhitespaces, ++compileAttempt);
} else {
throw error;
}
);
} catch (e) {
dymamicComponentFactorySubject.error(e);
}
}
})
);
})
);
return dymamicComponentFactorySubject.asObservable();
}
public destroyDynamicComponentFactory<T>(factory: ComponentFactory<T>) {
@ -103,10 +110,11 @@ export class DynamicComponentFactoryService {
}
}
private createDynamicComponent<T>(componentType: Type<T>, template: string): Type<T> {
private createDynamicComponent<T>(componentType: Type<T>, template: string, preserveWhitespaces?: boolean): Type<T> {
// noinspection AngularMissingOrInvalidDeclarationInModule
return Component({
template
template,
preserveWhitespaces
})(componentType);
}

29
ui-ngx/src/app/core/services/help.service.ts

@ -18,7 +18,8 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { catchError, mergeMap, tap } from 'rxjs/operators';
import { helpBaseUrl } from '@shared/models/constants';
const NOT_FOUND_CONTENT = '## Not found';
@ -27,6 +28,8 @@ const NOT_FOUND_CONTENT = '## Not found';
})
export class HelpService {
private helpBaseUrl = helpBaseUrl;
private helpCache: {[lang: string]: {[key: string]: string}} = {};
constructor(
@ -52,6 +55,9 @@ export class HelpService {
return of(NOT_FOUND_CONTENT);
}
}),
mergeMap((content) => {
return this.processIncludes(this.processVariables(content));
}),
tap((content) => {
let langContent = this.helpCache[lang];
if (!langContent) {
@ -68,4 +74,25 @@ export class HelpService {
return this.http.get(`/assets/help/${lang}/${key}.md`, {responseType: 'text'} );
}
private processVariables(content: string): string {
const baseUrlReg = /\${baseUrl}/g;
return content.replace(baseUrlReg, this.helpBaseUrl);
}
private processIncludes(content: string): Observable<string> {
const includesRule = /{% include (.*) %}/;
const match = includesRule.exec(content);
if (match) {
const key = match[1];
return this.getHelpContent(key).pipe(
mergeMap((include) => {
content = content.replace(match[0], include);
return this.processIncludes(content);
})
);
} else {
return of(content);
}
}
}

3
ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html

@ -46,7 +46,8 @@
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel']"
[disableUndefinedCheck]="true"
[validationArgs]="[]"
[editorCompleter]="customPrettyActionEditorCompleter">
[editorCompleter]="customPrettyActionEditorCompleter"
helpId="widget/action/custom_pretty_action_fn">
</tb-js-func>
</div>
</div>

3
ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html

@ -96,7 +96,8 @@
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel']"
[disableUndefinedCheck]="true"
[validationArgs]="[]"
[editorCompleter]="customPrettyActionEditorCompleter">
[editorCompleter]="customPrettyActionEditorCompleter"
helpId="widget/action/custom_pretty_action_fn">
</tb-js-func>
</mat-tab>
</mat-tab-group>

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

@ -37,6 +37,7 @@
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/mobile_get_location_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.makePhoneCall">
@ -46,6 +47,7 @@
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/mobile_get_phone_number_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionFormGroup.get('type').value === mobileActionType.takePhoto ||
@ -58,6 +60,7 @@
[functionArgs]="['imageUrl', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/mobile_process_image_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.scanQrCode">
@ -67,6 +70,7 @@
[functionArgs]="['code', 'format', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/mobile_process_qr_code_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionType.getLocation">
@ -76,6 +80,7 @@
[functionArgs]="['latitude', 'longitude', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/mobile_process_location_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="mobileActionFormGroup.get('type').value === mobileActionType.mapDirection ||
@ -88,6 +93,7 @@
[functionArgs]="['launched', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/mobile_process_launch_result_fn"
></tb-js-func>
</ng-template>
</div>
@ -97,6 +103,7 @@
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/mobile_handle_empty_result_fn"
></tb-js-func>
<tb-js-func *ngIf="mobileActionFormGroup.get('type').value"
formControlName="handleErrorFunction"
@ -104,5 +111,6 @@
[functionArgs]="['error', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']"
[globalVariables]="functionScopeVariables"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/mobile_handle_error_fn"
></tb-js-func>
</div>

6
ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts

@ -111,12 +111,6 @@ const processLocationFunction =
'showLocationDialog(\'Location\', latitude, longitude);\n' +
'// saveEntityLocationAttributes(\'latitude\', \'longitude\', latitude, longitude);\n' +
'\n' +
'function showImageDialog(title, imageUrl) {\n' +
' setTimeout(function() {\n' +
' widgetContext.customDialog.customDialog(imageDialogTemplate, ImageDialogController, {imageUrl: imageUrl, title: title}).subscribe();\n' +
' }, 100);\n' +
'}\n' +
'\n' +
'function saveEntityLocationAttributes(latitudeAttributeName, longitudeAttributeName, latitude, longitude) {\n' +
' if (entityId) {\n' +
' let attributes = [\n' +

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

@ -224,6 +224,7 @@
[globalVariables]="functionScopeVariables"
[validationArgs]="[]"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/custom_action_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.customPretty">

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

@ -15,7 +15,7 @@
limitations under the License.
-->
<form [formGroup]="dataKeyFormGroup" (ngSubmit)="save()" style="min-width: 480px;">
<form [formGroup]="dataKeyFormGroup" (ngSubmit)="save()" style="width: 700px;">
<mat-toolbar color="primary">
<h2>{{ 'datakey.configuration' | translate }}</h2>
<span fxFlex></span>

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

@ -70,6 +70,7 @@
[globalVariables]="functionScopeVariables"
[validationArgs]="[[1, 1],[1, '1']]"
resultType="any"
helpId="widget/config/datakey_generation_fn"
formControlName="funcBody">
</tb-js-func>
</section>
@ -82,6 +83,7 @@
[globalVariables]="functionScopeVariables"
[validationArgs]="[[1, 1, 1, 1, 1],[1, '1', '1', 1, '1']]"
resultType="any"
helpId="widget/config/datakey_postprocess_fn"
formControlName="postFuncBody">
</tb-js-func>
<label *ngIf="dataKeyFormGroup.get('usePostProcessing').value" class="tb-title" style="margin-left: 15px;">

2
ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html

@ -15,4 +15,4 @@
limitations under the License.
-->
<markdown [data]="markdownText" lineNumbers class="tb-markdown-view" (click)="markdownClick($event)"></markdown>
<tb-markdown [data]="markdownText" lineNumbers fallbackToPlainMarkdown (click)="markdownClick($event)"></tb-markdown>

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

@ -107,9 +107,9 @@ import { ComponentType } from '@angular/cdk/portal';
import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token';
import { MobileService } from '@core/services/mobile.service';
import { DialogService } from '@core/services/dialog.service';
import { TbPopoverService } from '@shared/components/popover.component';
import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component';
import { PopoverPlacement } from '@shared/components/popover.models';
import { TbPopoverService } from '@shared/components/popover.service';
@Component({
selector: 'tb-widget',

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

@ -251,6 +251,7 @@
<button mat-button (click)="beautifyJs()">
{{ 'widget.tidy' | translate }}
</button>
<span tb-help-popup="widget/editor/widget_js_fn" tb-help-popup-placement="top" [tb-help-popup-style]="{maxWidth: '1200px'}"></span>
<button mat-icon-button class="tb-mat-32"
(click)="javascriptFullscreen = !javascriptFullscreen"
matTooltip="{{(javascriptFullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"

18
ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.scss

@ -117,14 +117,17 @@ div.tb-editor-area-title-panel {
font-size: .8rem;
font-weight: 500;
& > * {
&:not(:last-child) {
margin-right: 4px;
}
}
label {
padding: 4px;
color: #00acc1;
background: rgba(220, 220, 220, .35);
border-radius: 5px;
&:not(:last-child) {
margin-right: 4px;
}
}
button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 {
@ -133,15 +136,16 @@ div.tb-editor-area-title-panel {
min-width: 32px;
min-height: 15px;
padding: 4px;
margin: 0;
font-size: .8rem;
line-height: 15px;
color: #7b7b7b;
background: rgba(220, 220, 220, .35);
&:not(:last-child) {
margin-right: 4px;
&:not(.tb-help-popup-button) {
color: #7b7b7b;
}
}
.tb-help-popup-button-loading {
background: #f3f3f3;
}
}
.tb-resize-container {

4
ui-ngx/src/app/shared/components/help-markdown.component.html

@ -15,6 +15,4 @@
limitations under the License.
-->
<ng-container *ngIf="markdownText$ | async as text;">
<markdown [data]="text" lineNumbers (ready)="onMarkdownReady()" class="tb-help-markdown tb-markdown-view" (click)="markdownClick($event)"></markdown>
</ng-container>
<tb-markdown [style]="style" [data]="markdownText$ | async" lineNumbers (ready)="onMarkdownReady()" markdownClass="tb-help-markdown" (click)="markdownClick($event)"></tb-markdown>

6
ui-ngx/src/app/shared/components/help-markdown.component.scss

@ -13,16 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
:host ::ng-deep {
.tb-help-markdown {
overflow: auto;
max-width: 80vw;
max-height: 80vh;
margin-top: 30px;
}
}
:host ::ng-deep {
.tb-help-markdown.tb-markdown-view {
h1, h2, h3, h4, h5, h6 {
&:first-child {

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

@ -22,7 +22,7 @@ import {
Output, SimpleChanges
} from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { delay, share } from 'rxjs/operators';
import { share } from 'rxjs/operators';
import { HelpService } from '@core/services/help.service';
@Component({
@ -34,8 +34,12 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
@Input() helpId: string;
@Input() helpContent: string;
@Input() visible: boolean;
@Input() style: { [klass: string]: any } = {};
@Output() markdownReady = new EventEmitter<void>();
markdownText = new BehaviorSubject<string>(null);
@ -44,8 +48,6 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
share()
);
isMarkdownReady = false;
private loadHelpPending = false;
constructor(private help: HelpService) {}
@ -68,7 +70,7 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
this.loadHelp();
}
}
if (propName === 'helpId') {
if (propName === 'helpId' || propName === 'helpContent') {
this.markdownText.next(null);
this.loadHelpWhenVisible();
}
@ -89,16 +91,16 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
this.help.getHelpContent(this.helpId).subscribe((content) => {
this.markdownText.next(content);
});
} else if (this.helpContent) {
this.markdownText.next(this.helpContent);
}
}
onMarkdownReady() {
this.isMarkdownReady = true;
this.markdownReady.next();
}
markdownClick($event: MouseEvent) {
}
}

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

@ -15,16 +15,28 @@
limitations under the License.
-->
<div style="position: relative;">
<button color="primary" mat-button mat-icon-button
class="tb-help-popup-button tb-mat-32"
type="button"
(click)="toggleHelp()"
matTooltip="{{'help.show-help' | translate}}"
matTooltipPosition="above">
<mat-icon class="material-icons">{{popoverVisible ? 'help' : 'help_outline'}}</mat-icon>
</button>
<div *ngIf="popoverVisible && !popoverReady" fxFlex fxLayoutAlign="center center" class="tb-absolute-fill tb-help-popup-button-loading">
<mat-spinner mode="indeterminate" diameter="20" strokeWidth="2"></mat-spinner>
</div>
</div>
<button #toggleHelpButton
*ngIf="!textMode"
mat-icon-button
color="primary"
class="tb-help-popup-button tb-mat-32"
type="button"
(click)="toggleHelp()"
matTooltip="{{'help.show-help' | translate}}"
matTooltipPosition="above">
<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>
</button>
<button #toggleHelpTextButton
*ngIf="textMode"
mat-button
color="primary"
class="tb-help-popup-text-button"
[ngClass]="{'mat-stroked-button': popoverVisible && popoverReady}"
(click)="toggleHelp()">
<ng-container *ngIf="triggerSafeHtml">
<span [style]="triggerStyle" [innerHTML]="triggerSafeHtml"></span>
</ng-container>
<mat-icon *ngIf="!popoverVisible || popoverReady" class="tb-mat-16">open_in_new</mat-icon>
<mat-spinner *ngIf="popoverVisible && !popoverReady" mode="indeterminate" diameter="16" strokeWidth="2"></mat-spinner>
</button>

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

@ -14,8 +14,36 @@
* limitations under the License.
*/
.tb-help-popup-button {
position: relative;
.mat-progress-spinner {
position: absolute;
top: 0;
left: 0;
background: #fff;
border-radius: 50%;
width: 32px !important;
height: 32px !important;
svg {
top: 6px;
left: 6px;
}
}
}
.tb-help-popup-button-loading {
background: #fff;
border-radius: 50%;
.tb-help-popup-text-button {
position: relative;
padding: 0 2px 0 8px;
line-height: 28px;
&.mat-stroked-button {
padding: 0 1px 0 7px;
line-height: 26px;
}
.mat-icon {
padding-left: 4px;
}
.mat-progress-spinner {
display: inline-block;
margin-left: 4px;
margin-right: 5px;
}
}

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

@ -14,8 +14,20 @@
/// limitations under the License.
///
import { Component, ElementRef, Input, OnDestroy, Renderer2, ViewContainerRef, ViewEncapsulation } from '@angular/core';
import { TbPopoverService } from '@shared/components/popover.component';
import {
Component,
ElementRef,
Input, OnChanges,
OnDestroy,
Renderer2, SimpleChanges,
ViewChild,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import { TbPopoverService } from '@shared/components/popover.service';
import { PopoverPlacement } from '@shared/components/popover.models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { isDefinedAndNotNull } from '@core/utils';
@Component({
// tslint:disable-next-line:component-selector
@ -24,27 +36,61 @@ import { TbPopoverService } from '@shared/components/popover.component';
styleUrls: ['./help-popup.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class HelpPopupComponent implements OnDestroy {
export class HelpPopupComponent implements OnChanges, OnDestroy {
@ViewChild('toggleHelpButton', {read: ElementRef, static: false}) toggleHelpButton: ElementRef;
@ViewChild('toggleHelpTextButton', {read: ElementRef, static: false}) toggleHelpTextButton: ElementRef;
// tslint:disable-next-line:no-input-rename
@Input('tb-help-popup') helpId: string;
// tslint:disable-next-line:no-input-rename
@Input('trigger-text') triggerText: string;
// tslint:disable-next-line:no-input-rename
@Input('trigger-style') triggerStyle: string;
// tslint:disable-next-line:no-input-rename
@Input('tb-help-popup-placement') helpPopupPlacement: PopoverPlacement;
// tslint:disable-next-line:no-input-rename
@Input('tb-help-popup-style') helpPopupStyle: { [klass: string]: any } = {};
popoverVisible = false;
popoverReady = true;
constructor(private elementRef: ElementRef,
private viewContainerRef: ViewContainerRef,
triggerSafeHtml: SafeHtml = null;
textMode = false;
constructor(private viewContainerRef: ViewContainerRef,
private element: ElementRef<HTMLElement>,
private sanitizer: DomSanitizer,
private renderer: Renderer2,
private popoverService: TbPopoverService) {}
private popoverService: TbPopoverService) {
}
ngOnChanges(changes: SimpleChanges): void {
if (isDefinedAndNotNull(this.triggerText)) {
this.triggerSafeHtml = this.sanitizer.bypassSecurityTrustHtml(this.triggerText);
} else {
this.triggerSafeHtml = null;
}
this.textMode = this.triggerSafeHtml != null;
}
toggleHelp() {
this.popoverService.toggleHelpPopover(this.elementRef.nativeElement, this.renderer, this.viewContainerRef,
const trigger = this.textMode ? this.toggleHelpTextButton.nativeElement : this.toggleHelpButton.nativeElement;
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef,
this.helpId,
'',
(visible) => {
this.popoverVisible = visible;
}, (ready => {
this.popoverReady = ready;
}));
}),
this.helpPopupPlacement,
{},
this.helpPopupStyle);
}
ngOnDestroy(): void {

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

@ -46,8 +46,7 @@ import { GroupInfo } from '@shared/models/widget.models';
import { Observable } from 'rxjs/internal/Observable';
import { forkJoin, from } from 'rxjs';
import { MouseEvent } from 'react';
import { TbPopoverService } from '@shared/components/popover.component';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
import { TbPopoverService } from '@shared/components/popover.service';
const tinycolor = tinycolor_;
@ -252,7 +251,7 @@ export class JsonFormComponent implements OnInit, ControlValueAccessor, Validato
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, '', helpVisibleFn, helpReadyFn);
}
private updateAndRender() {

26
ui-ngx/src/app/shared/components/markdown.component.html

@ -0,0 +1,26 @@
<!--
Copyright © 2016-2021 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<ng-container #markdownContainer>
</ng-container>
<div *ngIf="error && !fallbackToPlainMarkdown" style="color: #f00; font-size: 14px;
line-height: 28px;
background: #efefef;">
{{error}}
</div>
<div #fallbackElement [fxShow]="error && fallbackToPlainMarkdown" class="tb-markdown-view" [ngClass]="markdownClass" [ngStyle]="style">
</div>

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

@ -0,0 +1,194 @@
///
/// Copyright © 2016-2021 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
ChangeDetectorRef,
Component,
ComponentFactory,
ComponentRef, ElementRef,
EventEmitter,
Inject,
Injector,
Input, OnChanges,
Output,
SimpleChanges,
Type, ViewChild,
ViewContainerRef
} from '@angular/core';
import { HelpService } from '@core/services/help.service';
import { MarkdownService, PrismPlugin } from 'ngx-markdown';
import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { isDefinedAndNotNull } from '@core/utils';
import { Observable, of, ReplaySubject } from 'rxjs';
@Component({
selector: 'tb-markdown',
templateUrl: './markdown.component.html'
})
export class TbMarkdownComponent implements OnChanges {
@ViewChild('markdownContainer', {read: ViewContainerRef, static: true}) markdownContainer: ViewContainerRef;
@ViewChild('fallbackElement', {static: true}) fallbackElement: ElementRef<HTMLElement>;
@Input() data: string | undefined;
@Input() markdownClass: string | undefined;
@Input() style: { [klass: string]: any } = {};
@Input()
get lineNumbers(): boolean { return this.lineNumbersValue; }
set lineNumbers(value: boolean) { this.lineNumbersValue = coerceBooleanProperty(value); }
@Input()
get fallbackToPlainMarkdown(): boolean { return this.fallbackToPlainMarkdownValue; }
set fallbackToPlainMarkdown(value: boolean) { this.fallbackToPlainMarkdownValue = coerceBooleanProperty(value); }
@Output() ready = new EventEmitter<void>();
private lineNumbersValue = false;
private fallbackToPlainMarkdownValue = false;
isMarkdownReady = false;
error = null;
private tbMarkdownInstanceComponentRef: ComponentRef<any>;
private tbMarkdownInstanceComponentFactory: ComponentFactory<any>;
constructor(private help: HelpService,
private cd: ChangeDetectorRef,
public markdownService: MarkdownService,
@Inject(SHARED_MODULE_TOKEN) private sharedModule: Type<any>,
private dynamicComponentFactoryService: DynamicComponentFactoryService) {}
ngOnChanges(changes: SimpleChanges): void {
if (isDefinedAndNotNull(this.data)) {
this.render(this.data);
}
}
private render(markdown: string) {
const compiled = this.markdownService.compile(markdown, false);
let template = this.sanitizeCurlyBraces(compiled);
let markdownClass = 'tb-markdown-view';
if (this.markdownClass) {
markdownClass += ` ${this.markdownClass}`;
}
template = `<div [ngStyle]="style" class="${markdownClass}">${template}</div>`;
this.markdownContainer.clear();
const parent = this;
let readyObservable: Observable<void>;
this.dynamicComponentFactoryService.createDynamicComponentFactory(
class TbMarkdownInstance {
ngOnDestroy(): void {
parent.destroyMarkdownInstanceResources();
}
},
template,
[this.sharedModule],
true
).subscribe((factory) => {
this.tbMarkdownInstanceComponentFactory = factory;
const injector: Injector = Injector.create({providers: [], parent: this.markdownContainer.injector});
try {
this.tbMarkdownInstanceComponentRef =
this.markdownContainer.createComponent(this.tbMarkdownInstanceComponentFactory, 0, injector);
this.tbMarkdownInstanceComponentRef.instance.style = this.style;
this.handlePlugins(this.tbMarkdownInstanceComponentRef.location.nativeElement);
this.markdownService.highlight(this.tbMarkdownInstanceComponentRef.location.nativeElement);
readyObservable = this.handleImages(this.tbMarkdownInstanceComponentRef.location.nativeElement);
this.error = null;
} catch (error) {
readyObservable = this.handleError(compiled, error);
}
this.cd.detectChanges();
readyObservable.subscribe(() => {
this.ready.emit();
});
},
(error) => {
readyObservable = this.handleError(compiled, error);
this.cd.detectChanges();
readyObservable.subscribe(() => {
this.ready.emit();
});
});
}
private handleError(template: string, error): Observable<void> {
this.error = (error ? error + '' : 'Failed to render markdown!').replace(/\n/g, '<br>');
this.destroyMarkdownInstanceResources();
if (this.fallbackToPlainMarkdownValue) {
this.markdownContainer.clear();
const element = this.fallbackElement.nativeElement;
element.innerHTML = template;
this.handlePlugins(element);
this.markdownService.highlight(element);
return this.handleImages(element);
} else {
return of(null);
}
}
private handlePlugins(element: HTMLElement): void {
if (this.lineNumbers) {
this.setPluginClass(element, PrismPlugin.LineNumbers);
}
}
private setPluginClass(element: HTMLElement, plugin: string | string[]): void {
const preElements = element.querySelectorAll('pre');
for (let i = 0; i < preElements.length; i++) {
const classes = plugin instanceof Array ? plugin : [plugin];
preElements.item(i).classList.add(...classes);
}
}
private handleImages(element: HTMLElement): Observable<void> {
const imgs = $('img', element);
if (imgs.length) {
let totalImages = imgs.length;
const imagesLoadedSubject = new ReplaySubject<void>();
imgs.each((index, img) => {
$(img).one('load error', () => {
totalImages--;
if (totalImages === 0) {
imagesLoadedSubject.next();
imagesLoadedSubject.complete();
}
});
});
return imagesLoadedSubject.asObservable();
} else {
return of(null);
}
}
private sanitizeCurlyBraces(template: string): string {
return template.replace(/{/g, '&#123;').replace(/}/g, '&#125;');
}
private destroyMarkdownInstanceResources() {
if (this.tbMarkdownInstanceComponentFactory) {
this.dynamicComponentFactoryService.destroyDynamicComponentFactory(this.tbMarkdownInstanceComponentFactory);
this.tbMarkdownInstanceComponentFactory = null;
}
this.tbMarkdownInstanceComponentRef = null;
}
}

30
ui-ngx/src/app/shared/components/marked-options.service.ts

@ -21,6 +21,7 @@ import { DOCUMENT } from '@angular/common';
import { WINDOW } from '@core/services/window.service';
const copyCodeBlock = '{:copy-code}';
const autoBlock = '{:auto}';
const targetBlankBlock = '{:target=&quot;_blank&quot;}';
// @dynamic
@ -48,13 +49,25 @@ export class MarkedOptionsService extends MarkedOptions {
this.renderer.code = (code: string, language: string | undefined, isEscaped: boolean) => {
if (code.endsWith(copyCodeBlock)) {
code = code.substring(0, code.length - copyCodeBlock.length);
const content = checkLineNumbers(this.renderer2.code(code, language, isEscaped), code);
const content = postProcessCodeContent(this.renderer2.code(code, language, isEscaped), code);
this.id++;
return this.wrapCopyCode(this.id, content, code);
} else {
return this.wrapDiv(checkLineNumbers(this.renderer2.code(code, language, isEscaped), code));
return this.wrapDiv(postProcessCodeContent(this.renderer2.code(code, language, isEscaped), code));
}
};
this.renderer.table = (header: string, body: string) => {
let autoLayout = false;
if (header.includes(autoBlock)) {
autoLayout = true;
header = header.replace(autoBlock, '');
}
let table = this.renderer2.table(header, body);
if (autoLayout) {
table = table.replace('<table', '<table class="auto"');
}
return table;
};
this.renderer.tablecell = (content: string, flags: {
header: boolean;
align: 'center' | 'left' | 'right' | null;
@ -85,7 +98,7 @@ export class MarkedOptionsService extends MarkedOptions {
private wrapCopyCode(id: number, content: string, code: string): string {
return `<div class="code-wrapper noChars" id="codeWrapper${id}" onClick="markdownCopyCode(${id})">${content}` +
`<span id="copyCodeId${id}" style="display: none;">${code}</span>` +
`<span id="copyCodeId${id}" style="display: none;">${encodeURIComponent(code)}</span>` +
`<button class="clipboard-btn">\n` +
` <p>${this.translate.instant('markdown.copy-code')}</p>\n` +
` <div>\n` +
@ -119,7 +132,7 @@ export class MarkedOptionsService extends MarkedOptions {
private markdownCopyCode(id: number) {
const copyWrapper = $('#codeWrapper' + id);
if (copyWrapper.hasClass('noChars')) {
const text = $('#copyCodeId' + id).text();
const text = decodeURIComponent($('#copyCodeId' + id).text());
this.window.navigator.clipboard.writeText(text).then(() => {
import('tooltipster').then(
() => {
@ -151,10 +164,13 @@ export class MarkedOptionsService extends MarkedOptions {
}
}
function checkLineNumbers(content: string, code: string): string {
function postProcessCodeContent(content: string, code: string): string {
const lineCount = code.trim().split('\n').length;
let replacement;
if (lineCount < 2) {
content = content.replace('<pre>', '<pre class="no-line-numbers">');
replacement = '<pre ngNonBindable class="no-line-numbers">';
} else {
replacement = '<pre ngNonBindable>';
}
return content;
return content.replace('<pre>', replacement);
}

164
ui-ngx/src/app/shared/components/popover.component.ts

@ -25,7 +25,6 @@ import {
Directive,
ElementRef,
EventEmitter,
Injectable,
Injector,
Input,
OnChanges,
@ -36,7 +35,6 @@ import {
Renderer2,
SimpleChanges,
TemplateRef,
Type,
ViewChild,
ViewContainerRef,
ViewEncapsulation
@ -54,13 +52,11 @@ import {
getPlacementName,
popoverMotion,
PopoverPlacement,
PopoverWithTrigger,
POSITION_MAP,
PropertyMapping
} from '@shared/components/popover.models';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { isNotEmptyStr, onParentScrollOrWindowResize } from '@core/utils';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null;
@ -285,162 +281,6 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
}
}
@Injectable()
export class TbPopoverService {
private popoverWithTriggers: PopoverWithTrigger[] = [];
componentFactory: ComponentFactory<TbPopoverComponent> = this.resolver.resolveComponentFactory(TbPopoverComponent);
constructor(private resolver: ComponentFactoryResolver) {
}
hasPopover(trigger: Element): boolean {
const res = this.findPopoverByTrigger(trigger);
return res !== null;
}
hidePopover(trigger: Element): boolean {
const component: TbPopoverComponent = this.findPopoverByTrigger(trigger);
if (component && component.tbVisible) {
component.hide();
return true;
} else {
return false;
}
}
displayPopover<T>(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef,
componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true,
injector?: Injector, context?: any, overlayStyle: any = {}, popoverStyle: any = {}, style?: any): TbPopoverComponent {
const componentRef = hostView.createComponent(this.componentFactory);
const component = componentRef.instance;
this.popoverWithTriggers.push({
trigger,
popoverComponent: component
});
renderer.removeChild(
renderer.parentNode(trigger),
componentRef.location.nativeElement
);
const originElementRef = new ElementRef(trigger);
component.setOverlayOrigin({ elementRef: originElementRef });
component.tbPlacement = preferredPlacement;
component.tbComponentFactory = this.resolver.resolveComponentFactory(componentType);
component.tbComponentInjector = injector;
component.tbComponentContext = context;
component.tbOverlayStyle = overlayStyle;
component.tbPopoverInnerStyle = popoverStyle;
component.tbComponentStyle = style;
component.tbHideOnClickOutside = hideOnClickOutside;
component.tbVisibleChange.subscribe((visible: boolean) => {
if (!visible) {
component.tbAnimationDone.subscribe(() => {
componentRef.destroy();
});
}
});
component.tbDestroy.subscribe(() => {
this.removePopoverByComponent(component);
});
component.show();
return component;
}
toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId: string,
visibleFn: (visible: boolean) => void, readyFn: (ready: boolean) => void) {
if (this.hasPopover(trigger)) {
this.hidePopover(trigger);
} else {
readyFn(false);
const injector = Injector.create({
parent: hostView.injector, providers: []
});
const componentRef = hostView.createComponent(this.componentFactory);
const component = componentRef.instance;
this.popoverWithTriggers.push({
trigger,
popoverComponent: component
});
renderer.removeChild(
renderer.parentNode(trigger),
componentRef.location.nativeElement
);
const originElementRef = new ElementRef(trigger);
component.tbAnimationState = 'void';
component.tbOverlayStyle = { opacity: '0' };
component.setOverlayOrigin({ elementRef: originElementRef });
component.tbPlacement = 'bottom';
component.tbComponentFactory = this.resolver.resolveComponentFactory(HelpMarkdownComponent);
component.tbComponentInjector = injector;
component.tbComponentContext = {
helpId,
visible: true
};
component.tbHideOnClickOutside = true;
component.tbVisibleChange.subscribe((visible: boolean) => {
if (!visible) {
visibleFn(false);
component.tbAnimationDone.subscribe(() => {
componentRef.destroy();
});
}
});
component.tbDestroy.subscribe(() => {
this.removePopoverByComponent(component);
});
const showHelpMarkdownComponent = () => {
component.tbOverlayStyle = { opacity: '1' };
component.tbAnimationState = 'active';
component.updatePosition();
readyFn(true);
setTimeout(() => {
component.updatePosition();
});
};
const setupHelpMarkdownComponent = (helpMarkdownComponent: HelpMarkdownComponent) => {
if (helpMarkdownComponent.isMarkdownReady) {
showHelpMarkdownComponent();
} else {
helpMarkdownComponent.markdownReady.subscribe(() => {
showHelpMarkdownComponent();
});
}
};
if (component.tbComponentRef) {
setupHelpMarkdownComponent(component.tbComponentRef.instance);
} else {
component.tbComponentChange.subscribe((helpMarkdownComponentRef) => {
setupHelpMarkdownComponent(helpMarkdownComponentRef.instance);
});
}
component.show();
visibleFn(true);
}
}
private findPopoverByTrigger(trigger: Element): TbPopoverComponent | null {
const res = this.popoverWithTriggers.find(val => this.elementsAreEqualOrDescendant(trigger, val.trigger));
if (res) {
return res.popoverComponent;
} else {
return null;
}
}
private removePopoverByComponent(component: TbPopoverComponent): void {
const index = this.popoverWithTriggers.findIndex(val => val.popoverComponent === component);
if (index > -1) {
this.popoverWithTriggers.splice(index, 1);
}
}
private elementsAreEqualOrDescendant(element1: Element, element2: Element): boolean {
return element1 === element2 || element1.contains(element2) || element2.contains(element1);
}
}
@Component({
selector: 'tb-popover',
exportAs: 'tbPopoverComponent',
@ -703,10 +543,12 @@ export class TbPopoverComponent implements OnDestroy, OnInit {
updateStyles(): void {
this.classMap = {
[this.tbOverlayClassName]: true,
[`tb-popover-placement-${this.preferredPlacement}`]: true,
['tb-popover-hidden']: this.tbHidden || !this.lastIsIntersecting
};
if (this.tbOverlayClassName) {
this.classMap[this.tbOverlayClassName] = true;
}
}
setOverlayOrigin(origin: CdkOverlayOrigin): void {

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

@ -0,0 +1,190 @@
///
/// Copyright © 2016-2021 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
ComponentFactory,
ComponentFactoryResolver, ElementRef, Inject,
Injectable, Injector,
Renderer2,
Type,
ViewContainerRef
} from '@angular/core';
import { PopoverPlacement, PopoverWithTrigger } from '@shared/components/popover.models';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { ComponentType } from '@angular/cdk/portal';
import { HELP_MARKDOWN_COMPONENT_TOKEN } from '@shared/components/tokens';
@Injectable()
export class TbPopoverService {
private popoverWithTriggers: PopoverWithTrigger[] = [];
componentFactory: ComponentFactory<TbPopoverComponent> = this.resolver.resolveComponentFactory(TbPopoverComponent);
constructor(private resolver: ComponentFactoryResolver,
@Inject(HELP_MARKDOWN_COMPONENT_TOKEN) private helpMarkdownComponent: ComponentType<any>) {
}
hasPopover(trigger: Element): boolean {
const res = this.findPopoverByTrigger(trigger);
return res !== null;
}
hidePopover(trigger: Element): boolean {
const component: TbPopoverComponent = this.findPopoverByTrigger(trigger);
if (component && component.tbVisible) {
component.hide();
return true;
} else {
return false;
}
}
displayPopover<T>(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef,
componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true,
injector?: Injector, context?: any, overlayStyle: any = {}, popoverStyle: any = {}, style?: any): TbPopoverComponent {
const componentRef = hostView.createComponent(this.componentFactory);
const component = componentRef.instance;
this.popoverWithTriggers.push({
trigger,
popoverComponent: component
});
renderer.removeChild(
renderer.parentNode(trigger),
componentRef.location.nativeElement
);
const originElementRef = new ElementRef(trigger);
component.setOverlayOrigin({ elementRef: originElementRef });
component.tbPlacement = preferredPlacement;
component.tbComponentFactory = this.resolver.resolveComponentFactory(componentType);
component.tbComponentInjector = injector;
component.tbComponentContext = context;
component.tbOverlayStyle = overlayStyle;
component.tbPopoverInnerStyle = popoverStyle;
component.tbComponentStyle = style;
component.tbHideOnClickOutside = hideOnClickOutside;
component.tbVisibleChange.subscribe((visible: boolean) => {
if (!visible) {
component.tbAnimationDone.subscribe(() => {
componentRef.destroy();
});
}
});
component.tbDestroy.subscribe(() => {
this.removePopoverByComponent(component);
});
component.show();
return component;
}
toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId = '',
helpContent = '',
visibleFn: (visible: boolean) => void = () => {},
readyFn: (ready: boolean) => void = () => {},
preferredPlacement: PopoverPlacement = 'bottom',
overlayStyle: any = {}, helpStyle: any = {}) {
if (this.hasPopover(trigger)) {
this.hidePopover(trigger);
} else {
readyFn(false);
const injector = Injector.create({
parent: hostView.injector, providers: []
});
const componentRef = hostView.createComponent(this.componentFactory);
const component = componentRef.instance;
this.popoverWithTriggers.push({
trigger,
popoverComponent: component
});
renderer.removeChild(
renderer.parentNode(trigger),
componentRef.location.nativeElement
);
const originElementRef = new ElementRef(trigger);
component.tbAnimationState = 'void';
component.tbOverlayStyle = {...overlayStyle, opacity: '0' };
component.setOverlayOrigin({ elementRef: originElementRef });
component.tbPlacement = preferredPlacement;
component.tbComponentFactory = this.resolver.resolveComponentFactory(this.helpMarkdownComponent);
component.tbComponentInjector = injector;
component.tbComponentContext = {
helpId,
helpContent,
style: helpStyle,
visible: true
};
component.tbHideOnClickOutside = true;
component.tbVisibleChange.subscribe((visible: boolean) => {
if (!visible) {
visibleFn(false);
component.tbAnimationDone.subscribe(() => {
componentRef.destroy();
});
}
});
component.tbDestroy.subscribe(() => {
this.removePopoverByComponent(component);
});
const showHelpMarkdownComponent = () => {
component.tbOverlayStyle = {...component.tbOverlayStyle, opacity: '1' };
component.tbAnimationState = 'active';
component.updatePosition();
readyFn(true);
setTimeout(() => {
component.updatePosition();
});
};
const setupHelpMarkdownComponent = (helpMarkdownComponent: any) => {
if (helpMarkdownComponent.isMarkdownReady) {
showHelpMarkdownComponent();
} else {
helpMarkdownComponent.markdownReady.subscribe(() => {
showHelpMarkdownComponent();
});
}
};
if (component.tbComponentRef) {
setupHelpMarkdownComponent(component.tbComponentRef.instance);
} else {
component.tbComponentChange.subscribe((helpMarkdownComponentRef) => {
setupHelpMarkdownComponent(helpMarkdownComponentRef.instance);
});
}
component.show();
visibleFn(true);
}
}
private findPopoverByTrigger(trigger: Element): TbPopoverComponent | null {
const res = this.popoverWithTriggers.find(val => this.elementsAreEqualOrDescendant(trigger, val.trigger));
if (res) {
return res.popoverComponent;
} else {
return null;
}
}
private removePopoverByComponent(component: TbPopoverComponent): void {
const index = this.popoverWithTriggers.findIndex(val => val.popoverComponent === component);
if (index > -1) {
this.popoverWithTriggers.splice(index, 1);
}
}
private elementsAreEqualOrDescendant(element1: Element, element2: Element): boolean {
return element1 === element2 || element1.contains(element2) || element2.contains(element1);
}
}

24
ui-ngx/src/app/shared/components/tokens.ts

@ -0,0 +1,24 @@
///
/// Copyright © 2016-2021 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { InjectionToken, Type } from '@angular/core';
import { ComponentType } from '@angular/cdk/portal';
export const HELP_MARKDOWN_COMPONENT_TOKEN: InjectionToken<ComponentType<any>> =
new InjectionToken<ComponentType<any>>('HELP_MARKDOWN_COMPONENT_TOKEN');
export const SHARED_MODULE_TOKEN: InjectionToken<Type<any>> =
new InjectionToken<Type<any>>('HELP_MARKDOWN_COMPONENT_TOKEN');

2
ui-ngx/src/app/shared/models/constants.ts

@ -54,7 +54,7 @@ export const MediaBreakpoints = {
'gt-xl': 'screen and (min-width: 5001px)'
};
const helpBaseUrl = 'https://thingsboard.io';
export const helpBaseUrl = 'https://thingsboard.io';
export const HelpLinks = {
linksMap: {

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

@ -147,11 +147,14 @@ import { MAT_DATE_LOCALE } from '@angular/material/core';
import { CopyButtonComponent } from '@shared/components/button/copy-button.component';
import { TogglePasswordComponent } from '@shared/components/button/toggle-password.component';
import { HelpPopupComponent } from '@shared/components/help-popup.component';
import { TbPopoverComponent, TbPopoverDirective, TbPopoverService } from '@shared/components/popover.component';
import { TbPopoverComponent, TbPopoverDirective } from '@shared/components/popover.component';
import { TbStringTemplateOutletDirective } from '@shared/components/directives/sring-template-outlet.directive';
import { TbComponentOutletDirective} from '@shared/components/directives/component-outlet.directive';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
import { MarkedOptionsService } from '@shared/components/marked-options.service';
import { TbPopoverService } from '@shared/components/popover.service';
import { HELP_MARKDOWN_COMPONENT_TOKEN, SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { TbMarkdownComponent } from '@shared/components/markdown.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -174,6 +177,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
provide: MAT_DATE_LOCALE,
useValue: 'en-GB'
},
{ provide: HELP_MARKDOWN_COMPONENT_TOKEN, useValue: HelpMarkdownComponent },
{ provide: SHARED_MODULE_TOKEN, useValue: SharedModule },
TbPopoverService
],
declarations: [
@ -190,6 +195,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TbStringTemplateOutletDirective,
TbComponentOutletDirective,
TbPopoverDirective,
TbMarkdownComponent,
HelpComponent,
HelpMarkdownComponent,
HelpPopupComponent,
@ -336,6 +342,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TbStringTemplateOutletDirective,
TbComponentOutletDirective,
TbPopoverDirective,
TbMarkdownComponent,
HelpComponent,
HelpMarkdownComponent,
HelpPopupComponent,

19
ui-ngx/src/assets/help/en_US/widget/action/custom_action_args.md

@ -0,0 +1,19 @@
<li><b>$event:</b> <code><a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent" target="_blank">MouseEvent</a></code> - The <a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent" target="_blank">MouseEvent</a> object. Usually a result of a mouse click event.
</li>
<li><b>widgetContext:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API
and data used by widget instance.
</li>
<li><b>entityId:</b> <code>string</code> - An optional string id of the target entity.
</li>
<li><b>entityName:</b> <code>string</code> - An optional string name of the target entity.
</li>
<li><b>additionalParams:</b> <code>{[key: string]: any}</code> - An optional key/value object holding additional entity parameters.
<span style="padding-left: 4px;"
tb-help-popup="widget/action/custom_additional_params"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-text="Read more">
</span>
</li>
<li><b>entityLabel:</b> <code>string</code> - An optional string label of the target entity.
</li>

81
ui-ngx/src/assets/help/en_US/widget/action/custom_action_fn.md

@ -0,0 +1,81 @@
#### Custom action function
<div class="divider"></div>
<br/>
*function ($event, widgetContext, entityId, entityName, additionalParams, entityLabel): void*
A JavaScript function performing custom action.
**Parameters:**
<ul>
{% include widget/action/custom_action_args %}
</ul>
<div class="divider"></div>
##### Examples
* Display alert dialog with entity information:
```javascript
var title;
var content;
if (entityName) {
title = entityName + ' details';
content = '<b>Entity name</b>: ' + entityName;
if (additionalParams && additionalParams.entity) {
var entity = additionalParams.entity;
if (entity.id) {
content += '<br><b>Entity type</b>: ' + entity.id.entityType;
}
if (!isNaN(entity.temperature) && entity.temperature !== '') {
content += '<br><b>Temperature</b>: ' + entity.temperature + ' °C';
}
}
} else {
title = 'No entity information available';
content = '<b>No entity information available</b>';
}
showAlertDialog(title, content);
function showAlertDialog(title, content) {
setTimeout(function() {
widgetContext.dialogs.alert(title, content).subscribe();
}, 100);
}
{:copy-code}
```
* Delete device after confirmation:
```javascript
var $injector = widgetContext.$scope.$injector;
var dialogs = $injector.get(widgetContext.servicesMap.get('dialogs'));
var deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));
openDeleteDeviceDialog();
function openDeleteDeviceDialog() {
var title = 'Are you sure you want to delete the device ' + entityName + '?';
var content = 'Be careful, after the confirmation, the device and all related data will become unrecoverable!';
dialogs.confirm(title, content, 'Cancel', 'Delete').subscribe(
function(result) {
if (result) {
deleteDevice();
}
}
);
}
function deleteDevice() {
deviceService.deleteDevice(entityId.id).subscribe(
function() {
widgetContext.updateAliases();
}
);
}
{:copy-code}
```

53
ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md

@ -0,0 +1,53 @@
#### Additional params object
<div class="divider"></div>
<br/>
<b>additionalParams:</b> <code>{[key: string]: any}</code>
An optional key/value object holding additional entity parameters depending on widget type and action source:
<ul>
<li>Entities table widget (<i>On row click</i> or <i>Action cell button</i>) - <b>additionalParams:</b> <code>{ entity: <a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts#L61" target="_blank">EntityData</a> }</code>:
<ul>
<li><b>entity:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts#L61" target="_blank">EntityData</a></code> - An
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts#L61" target="_blank">EntityData</a> object
presenting basic entity properties (ex. <code>id</code>, <code>entityName</code>) and <br> provides access to other entity attributes/timeseries declared in widget datasource configuration.
</li>
</ul>
</li>
<li>Alarms table widget (<i>On row click</i> or <i>Action cell button</i>) - <b>additionalParams:</b> <code>{ alarm: <a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/shared/models/alarm.models.ts#L108" target="_blank">AlarmDataInfo</a> }</code>:
<ul>
<li><b>alarm:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/shared/models/alarm.models.ts#L108" target="_blank">AlarmDataInfo</a></code> - An
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/shared/models/alarm.models.ts#L108" target="_blank">AlarmDataInfo</a> object
presenting basic alarm properties (ex. <code>type</code>, <code>severity</code>, <code>originator</code>, etc.) and <br> provides access to other alarm or originator entity fields/attributes/timeseries declared in widget datasource configuration.
</li>
</ul>
</li>
<li>Timeseries table widget (<i>On row click</i> or <i>Action cell button</i>) - <b>additionalParams:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts#L80" target="_blank">TimeseriesRow</a></code>:
<ul>
<li><b>additionalParams:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts#L80" target="_blank">TimeseriesRow</a></code> - A
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts#L80" target="_blank">TimeseriesRow</a> object
presenting <code>formattedTs</code> (a string value of formatted timestamp) and <br> timeseries values for each column declared in widget datasource configuration.
</li>
</ul>
</li>
<li>Entities hierarchy widget (<i>On node selected</i>) - <b>additionalParams:</b> <code>{ nodeCtx: <a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts#L35" target="_blank">HierarchyNodeContext</a> }</code>:
<ul>
<li><b>nodeCtx:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts#L35" target="_blank">HierarchyNodeContext</a></code> - An
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts#L35" target="_blank">HierarchyNodeContext</a> object
containing <code>entity</code> field holding basic entity properties <br> (ex. <code>id</code>, <code>name</code>, <code>label</code>) and <code>data</code> field holding other entity attributes/timeseries declared in widget datasource configuration.
</li>
</ul>
</li>
<li>Pie - Flot widget (<i>On slice click</i>) - <b>additionalParams:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts#L62" target="_blank">TbFlotPlotItem</a></code>:
<ul>
<li><b>additionalParams:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts#L62" target="_blank">TbFlotPlotItem</a></code> - A
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts#L62" target="_blank">TbFlotPlotItem</a> object
containing <code>series</code> field with information about datasource and <br> data key of clicked pie slice.
</li>
</ul>
</li>
<li><i>All other widgets</i> - does not provide <b>additionalParams</b> value.
</li>
</ul>

85
ui-ngx/src/assets/help/en_US/widget/action/custom_pretty_action_fn.md

@ -0,0 +1,85 @@
#### Custom action (with HTML template) function
<div class="divider"></div>
<br/>
*function ($event, widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel): void*
A JavaScript function performing custom action with defined HTML template to render dialog.
**Parameters:**
<ul>
<li><b>$event:</b> <code><a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent" target="_blank">MouseEvent</a></code> - The <a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent" target="_blank">MouseEvent</a> object. Usually a result of a mouse click event.
</li>
<li><b>widgetContext:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API
and data used by widget instance.
</li>
<li><b>entityId:</b> <code>string</code> - An optional string id of the target entity.
</li>
<li><b>entityName:</b> <code>string</code> - An optional string name of the target entity.
</li>
<li><b>htmlTemplate:</b> <code>string</code> - An optional HTML template string defined in <b>HTML</b> tab.<br/> Used to render custom dialog (see <b>Examples</b> for more details).
</li>
<li><b>additionalParams:</b> <code>{[key: string]: any}</code> - An optional key/value object holding additional entity parameters.
<span style="padding-left: 4px;"
tb-help-popup="widget/action/custom_additional_params"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-text="Read more">
</span>
</li>
<li><b>entityLabel:</b> <code>string</code> - An optional string label of the target entity.
</li>
</ul>
<div class="divider"></div>
##### Examples
###### Display dialog to create a device or an asset
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/action/examples/custom_pretty_create_dialog_js"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="JavaScript function">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/action/examples/custom_pretty_create_dialog_html"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="HTML code">
</div>
###### Display dialog to edit a device or an asset
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/action/examples/custom_pretty_edit_dialog_js"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="JavaScript function">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/action/examples/custom_pretty_edit_dialog_html"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="HTML code">
</div>
<br>
<br>

160
ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_html.md

@ -0,0 +1,160 @@
#### HTML template of dialog to create a device or an asset
```html
<form #addEntityForm="ngForm" [formGroup]="addEntityFormGroup"
(ngSubmit)="save()" class="add-entity-form">
<mat-toolbar fxLayout="row" color="primary">
<h2>Add entity</h2>
<span fxFlex></span>
<button mat-icon-button (click)="cancel()" type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content fxLayout="column">
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Entity Name</mat-label>
<input matInput formControlName="entityName" required>
<mat-error *ngIf="addEntityFormGroup.get('entityName').hasError('required')">
Entity name is required.
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label>Entity Label</mat-label>
<input matInput formControlName="entityLabel" >
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<tb-entity-type-select
class="mat-block"
formControlName="entityType"
[showLabel]="true"
[allowedEntityTypes]="allowedEntityTypes"
></tb-entity-type-select>
<tb-entity-subtype-autocomplete
fxFlex *ngIf="addEntityFormGroup.get('entityType').value == 'ASSET'"
class="mat-block"
formControlName="type"
[required]="true"
[entityType]="'ASSET'"
></tb-entity-subtype-autocomplete>
<tb-entity-subtype-autocomplete
fxFlex *ngIf="addEntityFormGroup.get('entityType').value != 'ASSET'"
class="mat-block"
formControlName="type"
[required]="true"
[entityType]="'DEVICE'"
></tb-entity-subtype-autocomplete>
</div>
<div formGroupName="attributes" fxLayout="column">
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Latitude</mat-label>
<input type="number" step="any" matInput formControlName="latitude">
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label>Longitude</mat-label>
<input type="number" step="any" matInput formControlName="longitude">
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Address</mat-label>
<input matInput formControlName="address">
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label>Owner</mat-label>
<input matInput formControlName="owner">
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Integer Value</mat-label>
<input type="number" step="1" matInput formControlName="number">
<mat-error *ngIf="addEntityFormGroup.get('attributes.number').hasError('pattern')">
Invalid integer value.
</mat-error>
</mat-form-field>
<div class="boolean-value-input" fxLayout="column" fxLayoutAlign="center start" fxFlex>
<label class="checkbox-label">Boolean Value</label>
<mat-checkbox formControlName="booleanValue" style="margin-bottom: 40px;">
</mat-checkbox>
</div>
</div>
</div>
<div class="relations-list">
<div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div>
<div class="body" [fxShow]="relations().length">
<div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="relations" *ngFor="let relation of relations().controls; let i = index;">
<div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;">
<div fxFlex fxLayout="column">
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field class="mat-block" style="min-width: 100px;">
<mat-label>Direction</mat-label>
<mat-select formControlName="direction" name="direction">
<mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value">
</mat-option>
</mat-select>
<mat-error *ngIf="relation.get('direction').hasError('required')">
Relation direction is required.
</mat-error>
</mat-form-field>
<tb-relation-type-autocomplete
fxFlex class="mat-block"
formControlName="relationType"
[required]="true">
</tb-relation-type-autocomplete>
</div>
<div fxLayout="row" fxLayout.xs="column">
<tb-entity-select
fxFlex class="mat-block"
[required]="true"
formControlName="relatedEntity">
</tb-entity-select>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center center">
<button mat-icon-button color="primary"
aria-label="Remove"
type="button"
(click)="removeRelation(i)"
matTooltip="Remove relation"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</div>
</div>
<div>
<button mat-raised-button color="primary"
type="button"
(click)="addRelation()"
matTooltip="Add Relation"
matTooltipPosition="above">
Add
</button>
</div>
</div>
</div>
<div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
Cancel
</button>
<button mat-button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || addEntityForm.invalid || !addEntityForm.dirty">
Create
</button>
</div>
</form>
{:copy-code}
```

132
ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_js.md

@ -0,0 +1,132 @@
#### Function displaying dialog to create a device or an asset
```javascript
let $injector = widgetContext.$scope.$injector;
let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));
let assetService = $injector.get(widgetContext.servicesMap.get('assetService'));
let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));
let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));
let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService'));
openAddEntityDialog();
function openAddEntityDialog() {
customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe();
}
function AddEntityDialogController(instance) {
let vm = instance;
vm.allowedEntityTypes = ['ASSET', 'DEVICE'];
vm.entitySearchDirection = {
from: "FROM",
to: "TO"
}
vm.addEntityFormGroup = vm.fb.group({
entityName: ['', [vm.validators.required]],
entityType: ['DEVICE'],
entityLabel: [null],
type: ['', [vm.validators.required]],
attributes: vm.fb.group({
latitude: [null],
longitude: [null],
address: [null],
owner: [null],
number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]],
booleanValue: [null]
}),
relations: vm.fb.array([])
});
vm.cancel = function () {
vm.dialogRef.close(null);
};
vm.relations = function () {
return vm.addEntityFormGroup.get('relations');
};
vm.addRelation = function () {
vm.relations().push(vm.fb.group({
relatedEntity: [null, [vm.validators.required]],
relationType: [null, [vm.validators.required]],
direction: [null, [vm.validators.required]]
}));
};
vm.removeRelation = function (index) {
vm.relations().removeAt(index);
vm.relations().markAsDirty();
};
vm.save = function () {
vm.addEntityFormGroup.markAsPristine();
saveEntityObservable().subscribe(
function (entity) {
widgetContext.rxjs.forkJoin([
saveAttributes(entity.id),
saveRelations(entity.id)
]).subscribe(
function () {
widgetContext.updateAliases();
vm.dialogRef.close(null);
}
);
}
);
};
function saveEntityObservable() {
const formValues = vm.addEntityFormGroup.value;
let entity = {
name: formValues.entityName,
type: formValues.type,
label: formValues.entityLabel
};
if (formValues.entityType == 'ASSET') {
return assetService.saveAsset(entity);
} else if (formValues.entityType == 'DEVICE') {
return deviceService.saveDevice(entity);
}
}
function saveAttributes(entityId) {
let attributes = vm.addEntityFormGroup.get('attributes').value;
let attributesArray = [];
for (let key in attributes) {
if (attributes[key] !== null) {
attributesArray.push({key: key, value: attributes[key]});
}
}
if (attributesArray.length > 0) {
return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray);
}
return widgetContext.rxjs.of([]);
}
function saveRelations(entityId) {
let relations = vm.addEntityFormGroup.get('relations').value;
let tasks = [];
for (let i = 0; i < relations.length; i++) {
let relation = {
type: relations[i].relationType,
typeGroup: 'COMMON'
};
if (relations[i].direction == 'FROM') {
relation.to = relations[i].relatedEntity;
relation.from = entityId;
} else {
relation.to = entityId;
relation.from = relations[i].relatedEntity;
}
tasks.push(entityRelationService.saveRelation(relation));
}
if (tasks.length > 0) {
return widgetContext.rxjs.forkJoin(tasks);
}
return widgetContext.rxjs.of([]);
}
}
{:copy-code}
```

192
ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_html.md

@ -0,0 +1,192 @@
#### HTML template of dialog to edit a device or an asset
```html
<form #editEntityForm="ngForm" [formGroup]="editEntityFormGroup"
(ngSubmit)="save()" class="edit-entity-form">
<mat-toolbar fxLayout="row" color="primary">
<h2>Edit </h2>
<span fxFlex></span>
<button mat-icon-button (click)="cancel()" type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content fxLayout="column">
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Entity Name</mat-label>
<input matInput formControlName="entityName" required readonly="">
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label>Entity Label</mat-label>
<input matInput formControlName="entityLabel">
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Entity Type</mat-label>
<input matInput formControlName="entityType" readonly>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label>Type</mat-label>
<input matInput formControlName="type" readonly>
</mat-form-field>
</div>
<div formGroupName="attributes" fxLayout="column">
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Latitude</mat-label>
<input type="number" step="any" matInput formControlName="latitude">
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label>Longitude</mat-label>
<input type="number" step="any" matInput formControlName="longitude">
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Address</mat-label>
<input matInput formControlName="address">
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label>Owner</mat-label>
<input matInput formControlName="owner">
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field fxFlex class="mat-block">
<mat-label>Integer Value</mat-label>
<input type="number" step="1" matInput formControlName="number">
<mat-error *ngIf="editEntityFormGroup.get('attributes.number').hasError('pattern')">
Invalid integer value.
</mat-error>
</mat-form-field>
<div class="boolean-value-input" fxLayout="column" fxLayoutAlign="center start" fxFlex>
<label class="checkbox-label">Boolean Value</label>
<mat-checkbox formControlName="booleanValue" style="margin-bottom: 40px;">
</mat-checkbox>
</div>
</div>
</div>
<div class="relations-list old-relations">
<div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div>
<div class="body" [fxShow]="oldRelations().length">
<div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="oldRelations"
*ngFor="let relation of oldRelations().controls; let i = index;">
<div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;">
<div fxFlex fxLayout="column">
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field class="mat-block" style="min-width: 100px;">
<mat-label>Direction</mat-label>
<mat-select formControlName="direction" name="direction">
<mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value">
</mat-option>
</mat-select>
<mat-error *ngIf="relation.get('direction').hasError('required')">
Relation direction is required.
</mat-error>
</mat-form-field>
<tb-relation-type-autocomplete
fxFlex class="mat-block"
formControlName="relationType"
required="true">
</tb-relation-type-autocomplete>
</div>
<div fxLayout="row" fxLayout.xs="column">
<tb-entity-select
fxFlex class="mat-block"
required="true"
formControlName="relatedEntity">
</tb-entity-select>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center center">
<button mat-icon-button color="primary"
aria-label="Remove"
type="button"
(click)="removeOldRelation(i)"
matTooltip="Remove relation"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="relations-list">
<div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">New Relations</div>
<div class="body" [fxShow]="relations().length">
<div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="relations" *ngFor="let relation of relations().controls; let i = index;">
<div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;">
<div fxFlex fxLayout="column">
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">
<mat-form-field class="mat-block" style="min-width: 100px;">
<mat-label>Direction</mat-label>
<mat-select formControlName="direction" name="direction">
<mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value">
</mat-option>
</mat-select>
<mat-error *ngIf="relation.get('direction').hasError('required')">
Relation direction is required.
</mat-error>
</mat-form-field>
<tb-relation-type-autocomplete
fxFlex class="mat-block"
formControlName="relationType"
[required]="true">
</tb-relation-type-autocomplete>
</div>
<div fxLayout="row" fxLayout.xs="column">
<tb-entity-select
fxFlex class="mat-block"
[required]="true"
formControlName="relatedEntity">
</tb-entity-select>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center center">
<button mat-icon-button color="primary"
aria-label="Remove"
type="button"
(click)="removeRelation(i)"
matTooltip="Remove relation"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</div>
</div>
<div>
<button mat-raised-button color="primary"
type="button"
(click)="addRelation()"
matTooltip="Add Relation"
matTooltipPosition="above">
Add
</button>
</div>
</div>
</div>
<div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
Cancel
</button>
<button mat-button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || editEntityForm.invalid || !editEntityForm.dirty">
Save
</button>
</div>
</form>
{:copy-code}
```

220
ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_js.md

@ -0,0 +1,220 @@
#### Function displaying dialog to edit a device or an asset
```javascript
let $injector = widgetContext.$scope.$injector;
let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));
let entityService = $injector.get(widgetContext.servicesMap.get('entityService'));
let assetService = $injector.get(widgetContext.servicesMap.get('assetService'));
let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));
let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));
let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService'));
openEditEntityDialog();
function openEditEntityDialog() {
customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();
}
function EditEntityDialogController(instance) {
let vm = instance;
vm.entityName = entityName;
vm.entityType = entityId.entityType;
vm.entitySearchDirection = {
from: "FROM",
to: "TO"
};
vm.attributes = {};
vm.oldRelationsData = [];
vm.relationsToDelete = [];
vm.entity = {};
vm.editEntityFormGroup = vm.fb.group({
entityName: ['', [vm.validators.required]],
entityType: [null],
entityLabel: [null],
type: ['', [vm.validators.required]],
attributes: vm.fb.group({
latitude: [null],
longitude: [null],
address: [null],
owner: [null],
number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]],
booleanValue: [false]
}),
oldRelations: vm.fb.array([]),
relations: vm.fb.array([])
});
getEntityInfo();
vm.cancel = function() {
vm.dialogRef.close(null);
};
vm.relations = function() {
return vm.editEntityFormGroup.get('relations');
};
vm.oldRelations = function() {
return vm.editEntityFormGroup.get('oldRelations');
};
vm.addRelation = function() {
vm.relations().push(vm.fb.group({
relatedEntity: [null, [vm.validators.required]],
relationType: [null, [vm.validators.required]],
direction: [null, [vm.validators.required]]
}));
};
function addOldRelation() {
vm.oldRelations().push(vm.fb.group({
relatedEntity: [{value: null, disabled: true}, [vm.validators.required]],
relationType: [{value: null, disabled: true}, [vm.validators.required]],
direction: [{value: null, disabled: true}, [vm.validators.required]]
}));
}
vm.removeRelation = function(index) {
vm.relations().removeAt(index);
vm.relations().markAsDirty();
};
vm.removeOldRelation = function(index) {
vm.oldRelations().removeAt(index);
vm.relationsToDelete.push(vm.oldRelationsData[index]);
vm.oldRelations().markAsDirty();
};
vm.save = function() {
vm.editEntityFormGroup.markAsPristine();
widgetContext.rxjs.forkJoin([
saveAttributes(entityId),
saveRelations(entityId),
saveEntity()
]).subscribe(
function () {
widgetContext.updateAliases();
vm.dialogRef.close(null);
}
);
};
function getEntityAttributes(attributes) {
for (var i = 0; i < attributes.length; i++) {
vm.attributes[attributes[i].key] = attributes[i].value;
}
}
function getEntityRelations(relations) {
let relationsFrom = relations[0];
let relationsTo = relations[1];
for (let i=0; i < relationsFrom.length; i++) {
let relation = {
direction: 'FROM',
relationType: relationsFrom[i].type,
relatedEntity: relationsFrom[i].to
};
vm.oldRelationsData.push(relation);
addOldRelation();
}
for (let i=0; i < relationsTo.length; i++) {
let relation = {
direction: 'TO',
relationType: relationsTo[i].type,
relatedEntity: relationsTo[i].from
};
vm.oldRelationsData.push(relation);
addOldRelation();
}
}
function getEntityInfo() {
widgetContext.rxjs.forkJoin([
entityRelationService.findInfoByFrom(entityId),
entityRelationService.findInfoByTo(entityId),
attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE'),
entityService.getEntity(entityId.entityType, entityId.id)
]).subscribe(
function (data) {
getEntityRelations(data.slice(0,2));
getEntityAttributes(data[2]);
vm.entity = data[3];
vm.editEntityFormGroup.patchValue({
entityName: vm.entity.name,
entityType: vm.entityType,
entityLabel: vm.entity.label,
type: vm.entity.type,
attributes: vm.attributes,
oldRelations: vm.oldRelationsData
}, {emitEvent: false});
}
);
}
function saveEntity() {
const formValues = vm.editEntityFormGroup.value;
if (vm.entity.label !== formValues.entityLabel){
vm.entity.label = formValues.entityLabel;
if (formValues.entityType == 'ASSET') {
return assetService.saveAsset(vm.entity);
} else if (formValues.entityType == 'DEVICE') {
return deviceService.saveDevice(vm.entity);
}
}
return widgetContext.rxjs.of([]);
}
function saveAttributes(entityId) {
let attributes = vm.editEntityFormGroup.get('attributes').value;
let attributesArray = [];
for (let key in attributes) {
if (attributes[key] !== vm.attributes[key]) {
attributesArray.push({key: key, value: attributes[key]});
}
}
if (attributesArray.length > 0) {
return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray);
}
return widgetContext.rxjs.of([]);
}
function saveRelations(entityId) {
let relations = vm.editEntityFormGroup.get('relations').value;
let tasks = [];
for(let i=0; i < relations.length; i++) {
let relation = {
type: relations[i].relationType,
typeGroup: 'COMMON'
};
if (relations[i].direction == 'FROM') {
relation.to = relations[i].relatedEntity;
relation.from = entityId;
} else {
relation.to = entityId;
relation.from = relations[i].relatedEntity;
}
tasks.push(entityRelationService.saveRelation(relation));
}
for (let i=0; i < vm.relationsToDelete.length; i++) {
let relation = {
type: vm.relationsToDelete[i].relationType
};
if (vm.relationsToDelete[i].direction == 'FROM') {
relation.to = vm.relationsToDelete[i].relatedEntity;
relation.from = entityId;
} else {
relation.to = entityId;
relation.from = vm.relationsToDelete[i].relatedEntity;
}
tasks.push(entityRelationService.deleteRelation(relation.from, relation.type, relation.to));
}
if (tasks.length > 0) {
return widgetContext.rxjs.forkJoin(tasks);
}
return widgetContext.rxjs.of([]);
}
}
{:copy-code}
```

49
ui-ngx/src/assets/help/en_US/widget/action/mobile_get_location_fn.md

@ -0,0 +1,49 @@
#### Get location function
<div class="divider"></div>
<br/>
*function getLocation($event, widgetContext, entityId, entityName, additionalParams, entityLabel): [number, number] | Observable<[number, number]>*
A JavaScript function that should return location as array of two numbers (latitude, longitude) for further processing by mobile action.<br>
Usually location can be obtained from entity attributes/telemetry.
**Parameters:**
<ul>
{% include widget/action/custom_action_args %}
</ul>
**Returns:**
Latitude and longitude as array of two numbers or Observable of array of two numbers. For example ```[37.689, -122.433]```.
<div class="divider"></div>
##### Examples
* Return location from entity attributes:
```javascript
return getLocationFromEntityAttributes();
function getLocationFromEntityAttributes() {
if (entityId) {
return widgetContext.attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE', ['latitude', 'longitude'])
.pipe(widgetContext.rxjs
.map(function(attributeData) {
var res = [0,0];
if (attributeData && attributeData.length === 2) {
res[0] = attributeData.filter(function (data) { return data.key === 'latitude'})[0].value;
res[1] = attributeData.filter(function (data) { return data.key === 'longitude'})[0].value;
}
return res;
}
)
);
} else {
return [0,0];
}
}
{:copy-code}
```

48
ui-ngx/src/assets/help/en_US/widget/action/mobile_get_phone_number_fn.md

@ -0,0 +1,48 @@
#### Get phone number function
<div class="divider"></div>
<br/>
*function getPhoneNumber($event, widgetContext, entityId, entityName, additionalParams, entityLabel): number | string | Observable&lt;number&gt; | Observable&lt;string&gt;*
A JavaScript function that should return phone number for further processing by mobile action.<br>
Usually phone number can be obtained from entity attributes/telemetry.
**Parameters:**
<ul>
{% include widget/action/custom_action_args %}
</ul>
**Returns:**
String or numeric value of phone number or Observable of string or numeric value. For example ```123456789```.
<div class="divider"></div>
##### Examples
* Return phone number from entity attributes:
```javascript
return getPhoneNumberFromEntityAttributes();
function getPhoneNumberFromEntityAttributes() {
if (entityId) {
return widgetContext.attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE', ['phone'])
.pipe(widgetContext.rxjs
.map(function(attributeData) {
var res = 0;
if (attributeData && attributeData.length === 1) {
res = attributeData[0].value;
}
return res;
}
)
);
} else {
return 0;
}
}
{:copy-code}
```

31
ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_empty_result_fn.md

@ -0,0 +1,31 @@
#### Handle empty result function
<div class="divider"></div>
<br/>
*function handleEmptyResult($event, widgetContext, entityId, entityName, additionalParams, entityLabel): void*
An optional JavaScript function to handle empty result.<br>Usually this happens when user cancels the action (for ex. by pressing phone back button).
**Parameters:**
<ul>
{% include widget/action/custom_action_args %}
</ul>
<div class="divider"></div>
##### Examples
* Display alert dialog with canceled action message:
```javascript
showEmptyResultDialog('Action was canceled!');
function showEmptyResultDialog(message) {
setTimeout(function() {
widgetContext.dialogs.alert('Empty result', message).subscribe();
}, 100);
}
{:copy-code}
```

33
ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_error_fn.md

@ -0,0 +1,33 @@
#### Handle error function
<div class="divider"></div>
<br/>
*function handleError(error, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void*
An optional JavaScript function to handle error occurred while mobile action execution.
**Parameters:**
<ul>
<li><b>error:</b> <code>string</code> - error message.
</li>
{% include widget/action/custom_action_args %}
</ul>
<div class="divider"></div>
##### Examples
* Display alert dialog with error message:
```javascript
showErrorDialog('Failed to perform action', error);
function showErrorDialog(title, error) {
setTimeout(function() {
widgetContext.dialogs.alert(title, error).subscribe();
}, 100);
}
{:copy-code}
```

92
ui-ngx/src/assets/help/en_US/widget/action/mobile_process_image_fn.md

@ -0,0 +1,92 @@
#### Process image function
<div class="divider"></div>
<br/>
*function processImage(imageUrl, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void*
A JavaScript function to process image obtained as a result of mobile action (take photo, take image from gallery, etc.).
**Parameters:**
<ul>
<li><b>imageUrl:</b> <code>string</code> - An image URL in base64 data format.
</li>
{% include widget/action/custom_action_args %}
</ul>
<div class="divider"></div>
##### Examples
* Store image url data to entity attribute:
```javascript
saveEntityImageAttribute('image', imageUrl);
function saveEntityImageAttribute(attributeName, imageUrl) {
if (entityId) {
let attributes = [{
key: attributeName, value: imageUrl
}];
widgetContext.attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributes).subscribe(
function() {
widgetContext.showSuccessToast('Image attribute saved!');
},
function(error) {
widgetContext.dialogs.alert('Image attribute save failed', JSON.stringify(error));
}
);
}
}
{:copy-code}
```
* Display dialog with obtained image:
```javascript
showImageDialog('Image', imageUrl);
function showImageDialog(title, imageUrl) {
setTimeout(function() {
widgetContext.customDialog.customDialog(imageDialogTemplate, ImageDialogController, {imageUrl: imageUrl, title: title}).subscribe();
}, 100);
}
var imageDialogTemplate =
'<div aria-label="Image">' +
'<form #theForm="ngForm">' +
'<mat-toolbar fxLayout="row" color="primary">' +
'<h2>{{title}}</h2>' +
'<span fxFlex></span>' +
'<button mat-icon-button (click)="close()">' +
'<mat-icon>close</mat-icon>' +
'</button>' +
'</mat-toolbar>' +
'<div mat-dialog-content>' +
'<div class="mat-content mat-padding">' +
'<div fxLayout="column" fxFlex>' +
'<div style="padding-top: 20px;">' +
'<img [src]="imageUrl" style="height: 300px;"/>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div mat-dialog-actions fxLayout="row">' +
'<span fxFlex></span>' +
'<button mat-button (click)="close()" style="margin-right:20px;">Close</button>' +
'</div>' +
'</form>' +
'</div>';
function ImageDialogController(instance) {
let vm = instance;
vm.title = vm.data.title;
vm.imageUrl = vm.data.imageUrl;
vm.close = function ()
{
vm.dialogRef.close(null);
}
}
{:copy-code}
```

33
ui-ngx/src/assets/help/en_US/widget/action/mobile_process_launch_result_fn.md

@ -0,0 +1,33 @@
#### Process launch result function
<div class="divider"></div>
<br/>
*function processLaunchResult(launched, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void*
An optional JavaScript function to process result of attempt to launch external mobile application (for ex. map application or phone call application).
**Parameters:**
<ul>
<li><b>launched:</b> <code>boolean</code> - boolean value indicating if the external application was successfully launched.
{% include widget/action/custom_action_args %}
</ul>
<div class="divider"></div>
##### Examples
* Display alert dialog with external application launch status:
```javascript
showLaunchStatusDialog('Application', launched);
function showLaunchStatusDialog(title, status) {
setTimeout(function() {
widgetContext.dialogs.alert(title, status ? 'Successfully launched' : 'Failed to launch').subscribe();
}, 100);
}
{:copy-code}
```

59
ui-ngx/src/assets/help/en_US/widget/action/mobile_process_location_fn.md

@ -0,0 +1,59 @@
#### Process location function
<div class="divider"></div>
<br/>
*function processLocation(latitude, longitude, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void*
A JavaScript function to process current location of the phone.
**Parameters:**
<ul>
<li><b>latitude:</b> <code>number</code> - phone location latitude.
</li>
<li><b>longitude:</b> <code>number</code> - phone location longitude.
</li>
{% include widget/action/custom_action_args %}
</ul>
<div class="divider"></div>
##### Examples
* Display alert dialog with location data:
```javascript
showLocationDialog('Location', latitude, longitude);
function showLocationDialog(title, latitude, longitude) {
setTimeout(function() {
widgetContext.dialogs.alert(title, 'Latitude: '+latitude+'<br>Longitude: ' + longitude).subscribe();
}, 100);
}
{:copy-code}
```
* Store phone location to entity attributes:
```javascript
saveEntityLocationAttributes('latitude', 'longitude', latitude, longitude);
function saveEntityLocationAttributes(latitudeAttributeName, longitudeAttributeName, latitude, longitude) {
if (entityId) {
let attributes = [
{ key: latitudeAttributeName, value: latitude },
{ key: longitudeAttributeName, value: longitude }
];
widgetContext.attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributes).subscribe(
function() {
widgetContext.showSuccessToast('Location attributes saved!');
},
function(error) {
widgetContext.dialogs.alert('Location attributes save failed', JSON.stringify(error));
}
);
}
}
{:copy-code}
```

76
ui-ngx/src/assets/help/en_US/widget/action/mobile_process_qr_code_fn.md

@ -0,0 +1,76 @@
#### Process QR code function
<div class="divider"></div>
<br/>
*function processQrCode(code, format, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void*
A JavaScript function to process result of barcode scanning.
**Parameters:**
<ul>
<li><b>code:</b> <code>string</code> - A string value of scanned barcode.
</li>
<li><b>format:</b> <code>string</code> - barcode format. See <a href="https://github.com/juliuscanute/qr_code_scanner/blob/c89f1eaddb94cca705d7e602a0c326e271680bf4/lib/src/types/barcode_format.dart#L1" target="_blank">BarcodeFormat</a> enum for possible values.
</li>
{% include widget/action/custom_action_args %}
</ul>
<div class="divider"></div>
##### Examples
* Display alert dialog with scanned barcode:
```javascript
showQrCodeDialog('Bar Code', code, format);
function showQrCodeDialog(title, code, format) {
setTimeout(function() {
widgetContext.dialogs.alert(title, 'Code: ['+code+']<br>Format: ' + format).subscribe();
}, 100);
}
{:copy-code}
```
* Parse code as a device claiming info (in this case ```{deviceName: string, secretKey: string}```)<br>and then claim device (see [Claiming devices{:target="_blank"}](${baseUrl}/docs/user-guide/claiming-devices/) for details):
```javascript
var $scope = widgetContext.$scope;
var $injector = $scope.$injector;
var $translate = $injector.get(widgetContext.servicesMap.get('translate'));
var deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));
var deviceNotFound = $translate.instant('widgets.input-widgets.claim-not-found');
var failedClaimDevice = $translate.instant('widgets.input-widgets.claim-failed');
var claimDeviceInfo = JSON.parse(code);
var deviceName = claimDeviceInfo.deviceName;
var secretKey = claimDeviceInfo.secretKey;
var claimRequest = {
secretKey: secretKey
};
deviceService.claimDevice(deviceName, claimRequest, { ignoreErrors: true }).subscribe(
function (data) {
widgetContext.showSuccessToast('Device \'' + deviceName + '\' successfully claimed!');
widgetContext.updateAliases();
},
function (error) {
if(error.status == 404) {
widgetContext.showErrorToast(deviceNotFound);
} else {
if (error.status !== 400 && error.error && error.error.message) {
showDialog('Failed to claim device', error.error.message);
} else {
widgetContext.showErrorToast(failedClaimDevice);
}
}
}
);
function showDialog(title, error) {
setTimeout(function() {
widgetContext.dialogs.alert(title, error).subscribe();
}, 100);
}
{:copy-code}
```

47
ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md

@ -1,5 +1,48 @@
#### Show cell button action JavaScript Function
#### Show cell button action function
<div class="divider"></div>
<br/>
*function (widgetContext, data): boolean*
A JavaScript function evaluating whether to display particular table cell action.
**Parameters:**
<ul>
<li><b>widgetContext:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API
and data used by widget instance.
</li>
<li><b>data:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts#L108" target="_blank">FormattedData</a></code> - A <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts#L108" target="_blank">FormattedData</a> object of specific table row.<br/>
Represents basic entity properties (ex. <code>entityId</code>, <code>entityName</code>)<br/>and provides access to other entity attributes/timeseries declared in widget datasource configuration.
</li>
</ul>
**Returns:**
`true` if cell action should be displayed, `false` otherwise.
<div class="divider"></div>
##### Examples
* Display action only for customer users:
```javascript
return widgetContext.currentUser.authority === 'CUSTOMER_USER';
{:copy-code}
```
* Display action only if the entity in the row is device and has type `thermostat`:
```javascript
return data && data.entityType === 'DEVICE' && data.Type === 'thermostat';
{:copy-code}
```
* Display action only if the entity in the row has `temperature` latest timeseries or attribute value greater than 25:
```javascript
return data.entityName === 'Test device'; {:copy-code}
return data && data.temperature > 25;
{:copy-code}
```

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save