Browse Source

Merge pull request #10053 from thingsboard/feature/single-switch

Single Switch control widget
pull/10060/head
Igor Kulikov 3 years ago
committed by GitHub
parent
commit
9b20941464
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      application/src/main/data/json/system/widget_bundles/control_widgets.json
  2. 35
      application/src/main/data/json/system/widget_types/single_switch.json
  3. 5
      application/src/main/data/json/system/widget_types/slide_toggle_control.json
  4. 9
      application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java
  5. 4
      application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java
  7. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java
  8. 2
      dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java
  9. 10
      dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java
  10. 9
      dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java
  11. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java
  12. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java
  13. 3
      ui-ngx/angular.json
  14. 35
      ui-ngx/src/app/core/api/alias-controller.ts
  15. 26
      ui-ngx/src/app/core/api/widget-api.models.ts
  16. 355
      ui-ngx/src/app/core/api/widget-subscription.ts
  17. 4
      ui-ngx/src/app/core/http/device.service.ts
  18. 19
      ui-ngx/src/app/core/http/entity.service.ts
  19. 35
      ui-ngx/src/app/core/interceptors/global-http-interceptor.ts
  20. 104
      ui-ngx/src/app/core/services/dashboard-utils.service.ts
  21. 29
      ui-ngx/src/app/core/services/item-buffer.service.ts
  22. 26
      ui-ngx/src/app/core/services/utils.service.ts
  23. 28
      ui-ngx/src/app/core/utils.ts
  24. 12
      ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts
  25. 2
      ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html
  26. 7
      ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts
  27. 2
      ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html
  28. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html
  29. 4
      ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts
  30. 12
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  31. 214
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html
  32. 231
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts
  33. 38
      ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html
  34. 166
      ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts
  35. 3
      ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts
  36. 5
      ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts
  37. 2
      ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts
  38. 626
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widget.models.ts
  39. 42
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html
  40. 204
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.scss
  41. 202
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts
  42. 202
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts
  43. 211
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.html
  44. 176
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.ts
  45. 156
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings.component.ts
  46. 26
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.component.html
  47. 34
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.scss
  48. 47
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-panel.component.scss
  49. 201
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.html
  50. 180
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.ts
  51. 174
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings.component.ts
  52. 29
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  53. 21
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html
  54. 58
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts
  55. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html
  56. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.ts
  57. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html
  58. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts
  59. 198
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html
  60. 157
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts
  61. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html
  62. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.ts
  63. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html
  64. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts
  65. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html
  66. 16
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts
  67. 17
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  68. 3
      ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts
  69. 7
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  70. 22
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  71. 33
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  72. 19
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  73. 23
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  74. 2
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html
  75. 8
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html
  76. 25
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts
  77. 2
      ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html
  78. 21
      ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts
  79. 11
      ui-ngx/src/app/shared/components/fullscreen.directive.ts
  80. 9
      ui-ngx/src/app/shared/components/hotkeys.directive.ts
  81. 8
      ui-ngx/src/app/shared/components/popover.component.scss
  82. 37
      ui-ngx/src/app/shared/components/popover.component.ts
  83. 24
      ui-ngx/src/app/shared/components/popover.models.ts
  84. 16
      ui-ngx/src/app/shared/components/popover.service.ts
  85. 109
      ui-ngx/src/app/shared/components/value-input.component.html
  86. 32
      ui-ngx/src/app/shared/components/value-input.component.scss
  87. 97
      ui-ngx/src/app/shared/components/value-input.component.ts
  88. 31
      ui-ngx/src/app/shared/import-export/import-export.service.ts
  89. 2
      ui-ngx/src/app/shared/models/alias.models.ts
  90. 125
      ui-ngx/src/app/shared/models/rpc-widget-settings.models.ts
  91. 10
      ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts
  92. 19
      ui-ngx/src/app/shared/models/widget.models.ts
  93. 86
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  94. 21
      ui-ngx/src/assets/widget/single-switch/centered-layout.svg
  95. 21
      ui-ngx/src/assets/widget/single-switch/left-layout.svg
  96. 21
      ui-ngx/src/assets/widget/single-switch/right-layout.svg
  97. 83
      ui-ngx/src/form.scss
  98. 3
      ui-ngx/src/styles.scss

4
application/src/main/data/json/system/widget_bundles/control_widgets.json

File diff suppressed because one or more lines are too long

35
application/src/main/data/json/system/widget_types/single_switch.json

File diff suppressed because one or more lines are too long

5
application/src/main/data/json/system/widget_types/slide_toggle_control.json

File diff suppressed because one or more lines are too long

9
application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java

@ -20,6 +20,7 @@ import io.swagger.annotations.ApiParam;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestMethod;
@ -43,6 +44,8 @@ import org.thingsboard.server.service.query.EntityQueryService;
import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Operation;
import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_SCOPE_ALLOWED_VALUES;
import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_SCOPE_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION;
@ -119,14 +122,16 @@ public class EntityQueryController extends BaseController {
@ApiParam(value = "Include all unique time-series keys to the result.") @ApiParam(value = "Include all unique time-series keys to the result.")
@RequestParam("timeseries") boolean isTimeseries, @RequestParam("timeseries") boolean isTimeseries,
@ApiParam(value = "Include all unique attribute keys to the result.") @ApiParam(value = "Include all unique attribute keys to the result.")
@RequestParam("attributes") boolean isAttributes) throws ThingsboardException { @RequestParam("attributes") boolean isAttributes,
@ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES)
@RequestParam(value = "scope", required = false) String scope) throws ThingsboardException {
TenantId tenantId = getTenantId(); TenantId tenantId = getTenantId();
checkNotNull(query); checkNotNull(query);
EntityDataPageLink pageLink = query.getPageLink(); EntityDataPageLink pageLink = query.getPageLink();
if (pageLink.getPageSize() > MAX_PAGE_SIZE) { if (pageLink.getPageSize() > MAX_PAGE_SIZE) {
pageLink.setPageSize(MAX_PAGE_SIZE); pageLink.setPageSize(MAX_PAGE_SIZE);
} }
return entityQueryService.getKeysByQuery(getCurrentUser(), tenantId, query, isTimeseries, isAttributes); return entityQueryService.getKeysByQuery(getCurrentUser(), tenantId, query, isTimeseries, isAttributes, scope);
} }
} }

4
application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java

@ -225,7 +225,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
@Override @Override
public DeferredResult<ResponseEntity> getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, public DeferredResult<ResponseEntity> getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query,
boolean isTimeseries, boolean isAttributes) { boolean isTimeseries, boolean isAttributes, String attributesScope) {
final DeferredResult<ResponseEntity> response = new DeferredResult<>(); final DeferredResult<ResponseEntity> response = new DeferredResult<>();
if (!isAttributes && !isTimeseries) { if (!isAttributes && !isTimeseries) {
replyWithEmptyResponse(response); replyWithEmptyResponse(response);
@ -253,7 +253,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
if (isAttributes) { if (isAttributes) {
Map<EntityType, List<EntityId>> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType)); Map<EntityType, List<EntityId>> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType));
List<ListenableFuture<List<String>>> futures = new ArrayList<>(typesMap.size()); List<ListenableFuture<List<String>>> futures = new ArrayList<>(typesMap.size());
typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, type, entityIds)))); typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, type, entityIds, attributesScope))));
attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> { attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> {
if (CollectionUtils.isEmpty(lists)) { if (CollectionUtils.isEmpty(lists)) {
return Collections.emptyList(); return Collections.emptyList();

2
application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java

@ -38,6 +38,6 @@ public interface EntityQueryService {
long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query); long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query);
DeferredResult<ResponseEntity> getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, DeferredResult<ResponseEntity> getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query,
boolean isTimeseries, boolean isAttributes); boolean isTimeseries, boolean isAttributes, String attributesScope);
} }

2
common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java

@ -47,4 +47,6 @@ public interface AttributesService {
List<String> findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List<EntityId> entityIds); List<String> findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List<EntityId> entityIds);
List<String> findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List<EntityId> entityIds, String scope);
} }

2
dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java

@ -44,4 +44,6 @@ public interface AttributesDao {
List<String> findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId); List<String> findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId);
List<String> findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List<EntityId> entityIds); List<String> findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List<EntityId> entityIds);
List<String> findAllKeysByEntityIdsAndAttributeType(TenantId tenantId, EntityType entityType, List<EntityId> entityIds, String attributeType);
} }

10
dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java

@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
@ -83,6 +84,15 @@ public class BaseAttributesService implements AttributesService {
return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds); return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds);
} }
@Override
public List<String> findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List<EntityId> entityIds, String scope) {
if (StringUtils.isEmpty(scope)) {
return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds);
} else {
return attributesDao.findAllKeysByEntityIdsAndAttributeType(tenantId, entityType, entityIds, scope);
}
}
@Override @Override
public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) { public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) {
validate(entityId, scope); validate(entityId, scope);

9
dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java

@ -220,6 +220,15 @@ public class CachedAttributesService implements AttributesService {
return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds); return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds);
} }
@Override
public List<String> findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List<EntityId> entityIds, String scope) {
if (StringUtils.isEmpty(scope)) {
return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds);
} else {
return attributesDao.findAllKeysByEntityIdsAndAttributeType(tenantId, entityType, entityIds, scope);
}
}
@Override @Override
public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) { public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) {
validate(entityId, scope); validate(entityId, scope);

6
dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java

@ -59,5 +59,11 @@ public interface AttributeKvRepository extends JpaRepository<AttributeKvEntity,
@Query(value = "SELECT DISTINCT attribute_key FROM attribute_kv WHERE entity_type = :entityType " + @Query(value = "SELECT DISTINCT attribute_key FROM attribute_kv WHERE entity_type = :entityType " +
"AND entity_id in :entityIds ORDER BY attribute_key", nativeQuery = true) "AND entity_id in :entityIds ORDER BY attribute_key", nativeQuery = true)
List<String> findAllKeysByEntityIds(@Param("entityType") String entityType, @Param("entityIds") List<UUID> entityIds); List<String> findAllKeysByEntityIds(@Param("entityType") String entityType, @Param("entityIds") List<UUID> entityIds);
@Query(value = "SELECT DISTINCT attribute_key FROM attribute_kv WHERE entity_type = :entityType " +
"AND entity_id in :entityIds AND attribute_type = :attributeType ORDER BY attribute_key", nativeQuery = true)
List<String> findAllKeysByEntityIdsAndAttributeType(@Param("entityType") String entityType,
@Param("entityIds") List<UUID> entityIds,
@Param("attributeType") String attributeType);
} }

6
dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java

@ -153,6 +153,12 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl
.findAllKeysByEntityIds(entityType.name(), entityIds.stream().map(EntityId::getId).collect(Collectors.toList())); .findAllKeysByEntityIds(entityType.name(), entityIds.stream().map(EntityId::getId).collect(Collectors.toList()));
} }
@Override
public List<String> findAllKeysByEntityIdsAndAttributeType(TenantId tenantId, EntityType entityType, List<EntityId> entityIds, String attributeType) {
return attributeKvRepository
.findAllKeysByEntityIdsAndAttributeType(entityType.name(), entityIds.stream().map(EntityId::getId).collect(Collectors.toList()), attributeType);
}
@Override @Override
public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String attributeType, AttributeKvEntry attribute) { public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String attributeType, AttributeKvEntry attribute) {
AttributeKvEntity entity = new AttributeKvEntity(); AttributeKvEntity entity = new AttributeKvEntity();

3
ui-ngx/angular.json

@ -158,7 +158,8 @@
"ace-builds", "ace-builds",
"diff-match-patch", "diff-match-patch",
"tv4", "tv4",
"@messageformat/parser" "@messageformat/parser",
"sorted-btree"
] ]
}, },
"configurations": { "configurations": {

35
ui-ngx/src/app/core/api/alias-controller.ts

@ -16,7 +16,14 @@
import { AliasInfo, IAliasController, StateControllerHolder, StateEntityInfo } from '@core/api/widget-api.models'; import { AliasInfo, IAliasController, StateControllerHolder, StateEntityInfo } from '@core/api/widget-api.models';
import { forkJoin, Observable, of, ReplaySubject, Subject } from 'rxjs'; import { forkJoin, Observable, of, ReplaySubject, Subject } from 'rxjs';
import { Datasource, DatasourceType, datasourceTypeTranslationMap } from '@app/shared/models/widget.models'; import {
Datasource,
DatasourceType,
datasourceTypeTranslationMap,
TargetDevice,
TargetDeviceType,
targetDeviceValid
} from '@app/shared/models/widget.models';
import { deepClone, isDefinedAndNotNull, isEqual } from '@core/utils'; import { deepClone, isDefinedAndNotNull, isEqual } from '@core/utils';
import { EntityService } from '@core/http/entity.service'; import { EntityService } from '@core/http/entity.service';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
@ -24,8 +31,6 @@ import { AliasFilterType, EntityAliases, SingleEntityFilter } from '@shared/mode
import { EntityInfo } from '@shared/models/entity.models'; import { EntityInfo } from '@shared/models/entity.models';
import { map, mergeMap } from 'rxjs/operators'; import { map, mergeMap } from 'rxjs/operators';
import { import {
AlarmFilter,
AlarmFilterConfig,
createDefaultEntityDataPageLink, createDefaultEntityDataPageLink,
Filter, Filter,
FilterInfo, FilterInfo,
@ -53,10 +58,10 @@ export class AliasController implements IAliasController {
filters: Filters; filters: Filters;
userFilters: Filters; userFilters: Filters;
resolvedAliases: {[aliasId: string]: AliasInfo} = {}; resolvedAliases: { [aliasId: string]: AliasInfo } = {};
resolvedAliasesObservable: {[aliasId: string]: Observable<AliasInfo>} = {}; resolvedAliasesObservable: { [aliasId: string]: Observable<AliasInfo> } = {};
resolvedAliasesToStateEntities: {[aliasId: string]: StateEntityInfo} = {}; resolvedAliasesToStateEntities: { [aliasId: string]: StateEntityInfo } = {};
constructor(private utils: UtilsService, constructor(private utils: UtilsService,
private entityService: EntityService, private entityService: EntityService,
@ -250,6 +255,24 @@ export class AliasController implements IAliasController {
); );
} }
resolveSingleEntityInfoForDeviceId(deviceId: string): Observable<EntityInfo> {
const entityFilter = singleEntityFilterFromDeviceId(deviceId);
return this.entityService.findSingleEntityInfoByEntityFilter(entityFilter,
{ignoreLoading: true, ignoreErrors: true});
}
resolveSingleEntityInfoForTargetDevice(targetDevice: TargetDevice): Observable<EntityInfo> {
if (targetDeviceValid(targetDevice)) {
if (targetDevice.type === TargetDeviceType.entity) {
return this.resolveSingleEntityInfo(targetDevice.entityAliasId);
} else {
return this.resolveSingleEntityInfoForDeviceId(targetDevice.deviceId);
}
} else {
return of(null);
}
}
private resolveDatasource(datasource: Datasource, forceFilter = false): Observable<Datasource> { private resolveDatasource(datasource: Datasource, forceFilter = false): Observable<Datasource> {
const newDatasource = deepClone(datasource); const newDatasource = deepClone(datasource);
if (newDatasource.type === DatasourceType.entity if (newDatasource.type === DatasourceType.entity

26
ui-ngx/src/app/core/api/widget-api.models.ts

@ -23,7 +23,7 @@ import {
DatasourceType, DatasourceType,
KeyInfo, KeyInfo,
LegendConfig, LegendConfig,
LegendData, LegendData, TargetDevice,
WidgetActionDescriptor, WidgetActionDescriptor,
widgetType widgetType
} from '@shared/models/widget.models'; } from '@shared/models/widget.models';
@ -37,7 +37,7 @@ import { RafService } from '@core/services/raf.service';
import { EntityAliases } from '@shared/models/alias.models'; import { EntityAliases } from '@shared/models/alias.models';
import { EntityInfo } from '@app/shared/models/entity.models'; import { EntityInfo } from '@app/shared/models/entity.models';
import { IDashboardComponent } from '@home/models/dashboard-component.models'; import { IDashboardComponent } from '@home/models/dashboard-component.models';
import * as moment_ from 'moment'; import moment_ from 'moment';
import { import {
AlarmData, AlarmData,
AlarmDataPageLink, AlarmDataPageLink,
@ -57,6 +57,7 @@ import { IDashboardController } from '@home/components/dashboard-page/dashboard-
import { PopoverPlacement } from '@shared/components/popover.models'; import { PopoverPlacement } from '@shared/components/popover.models';
import { PersistentRpc } from '@shared/models/rpc.models'; import { PersistentRpc } from '@shared/models/rpc.models';
import { EventEmitter } from '@angular/core'; import { EventEmitter } from '@angular/core';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
export interface TimewindowFunctions { export interface TimewindowFunctions {
onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void; onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void;
@ -121,18 +122,20 @@ export interface IAliasController {
getEntityAliasId(aliasName: string): string; getEntityAliasId(aliasName: string): string;
getInstantAliasInfo(aliasId: string): AliasInfo; getInstantAliasInfo(aliasId: string): AliasInfo;
resolveSingleEntityInfo(aliasId: string): Observable<EntityInfo>; resolveSingleEntityInfo(aliasId: string): Observable<EntityInfo>;
resolveSingleEntityInfoForDeviceId(deviceId: string): Observable<EntityInfo>;
resolveSingleEntityInfoForTargetDevice(targetDevice: TargetDevice): Observable<EntityInfo>;
resolveDatasources(datasources: Array<Datasource>, singleEntity?: boolean, pageSize?: number): Observable<Array<Datasource>>; resolveDatasources(datasources: Array<Datasource>, singleEntity?: boolean, pageSize?: number): Observable<Array<Datasource>>;
resolveAlarmSource(alarmSource: Datasource): Observable<Datasource>; resolveAlarmSource(alarmSource: Datasource): Observable<Datasource>;
getEntityAliases(): EntityAliases; getEntityAliases(): EntityAliases;
getFilters(): Filters; getFilters(): Filters;
getFilterInfo(filterId: string): FilterInfo; getFilterInfo(filterId: string): FilterInfo;
getKeyFilters(filterId: string): Array<KeyFilter>; getKeyFilters(filterId: string): Array<KeyFilter>;
updateCurrentAliasEntity(aliasId: string, currentEntity: EntityInfo); updateCurrentAliasEntity(aliasId: string, currentEntity: EntityInfo): void;
updateUserFilter(filter: Filter); updateUserFilter(filter: Filter): void;
updateEntityAliases(entityAliases: EntityAliases); updateEntityAliases(entityAliases: EntityAliases): void;
updateFilters(filters: Filters); updateFilters(filters: Filters): void;
updateAliases(aliasIds?: Array<string>); updateAliases(aliasIds?: Array<string>): void;
dashboardStateChanged(); dashboardStateChanged(): void;
} }
export interface StateObject { export interface StateObject {
@ -208,6 +211,7 @@ export class WidgetSubscriptionContext {
entityDataService: EntityDataService; entityDataService: EntityDataService;
alarmDataService: AlarmDataService; alarmDataService: AlarmDataService;
utils: UtilsService; utils: UtilsService;
dashboardUtils: DashboardUtilsService;
raf: RafService; raf: RafService;
widgetUtils: IWidgetUtils; widgetUtils: IWidgetUtils;
getServerTimeDiff: () => Observable<number>; getServerTimeDiff: () => Observable<number>;
@ -250,6 +254,7 @@ export interface WidgetSubscriptionOptions {
ignoreDataUpdateOnIntervalTick?: boolean; ignoreDataUpdateOnIntervalTick?: boolean;
targetDeviceAliasIds?: Array<string>; targetDeviceAliasIds?: Array<string>;
targetDeviceIds?: Array<string>; targetDeviceIds?: Array<string>;
targetDevice?: TargetDevice;
useDashboardTimewindow?: boolean; useDashboardTimewindow?: boolean;
displayTimewindow?: boolean; displayTimewindow?: boolean;
timeWindowConfig?: Timewindow; timeWindowConfig?: Timewindow;
@ -305,13 +310,12 @@ export interface IWidgetSubscription {
alarms?: PageData<AlarmData>; alarms?: PageData<AlarmData>;
alarmSource?: Datasource; alarmSource?: Datasource;
targetDeviceAliasIds?: Array<string>; targetEntityId?: EntityId;
targetDeviceIds?: Array<string>;
rpcEnabled?: boolean; rpcEnabled?: boolean;
executingRpcRequest?: boolean; executingRpcRequest?: boolean;
rpcErrorText?: string; rpcErrorText?: string;
rpcRejection?: HttpErrorResponse; rpcRejection?: HttpErrorResponse | Error;
getFirstEntityInfo(): SubscriptionEntityInfo; getFirstEntityInfo(): SubscriptionEntityInfo;

355
ui-ngx/src/app/core/api/widget-subscription.ts

@ -34,6 +34,9 @@ import {
LegendData, LegendData,
LegendKey, LegendKey,
LegendKeyData, LegendKeyData,
TargetDevice,
TargetDeviceType,
targetDeviceValid,
widgetType widgetType
} from '@app/shared/models/widget.models'; } from '@app/shared/models/widget.models';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
@ -52,7 +55,7 @@ import {
} from '@app/shared/models/time/time.models'; } from '@app/shared/models/time/time.models';
import { forkJoin, Observable, of, ReplaySubject, Subject, throwError, timer } from 'rxjs'; import { forkJoin, Observable, of, ReplaySubject, Subject, throwError, timer } from 'rxjs';
import { CancelAnimationFrame } from '@core/services/raf.service'; import { CancelAnimationFrame } from '@core/services/raf.service';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { import {
createLabelFromPattern, createLabelFromPattern,
deepClone, deepClone,
@ -64,7 +67,7 @@ import {
parseHttpErrorMessage parseHttpErrorMessage
} from '@core/utils'; } from '@core/utils';
import { EntityId } from '@app/shared/models/id/entity-id'; import { EntityId } from '@app/shared/models/id/entity-id';
import * as moment_ from 'moment'; import moment_ from 'moment';
import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { EntityDataListener } from '@core/api/entity-data.service'; import { EntityDataListener } from '@core/api/entity-data.service';
import { import {
@ -200,22 +203,21 @@ export class WidgetSubscription implements IWidgetSubscription {
loadingData: boolean; loadingData: boolean;
targetDeviceAliasIds?: Array<string>;
targetDeviceIds?: Array<string>;
executingRpcRequest: boolean; executingRpcRequest: boolean;
rpcEnabled: boolean; rpcEnabled: boolean;
rpcDisabledReason: string;
rpcErrorText: string; rpcErrorText: string;
rpcRejection: HttpErrorResponse; rpcRejection: HttpErrorResponse | Error;
init$: Observable<IWidgetSubscription>; init$: Observable<IWidgetSubscription>;
cafs: {[cafId: string]: CancelAnimationFrame} = {}; cafs: {[cafId: string]: CancelAnimationFrame} = {};
hasResolvedData = false; hasResolvedData = false;
targetDeviceAliasId: string; targetEntityId?: EntityId;
targetEntityName?: string;
targetDevice: TargetDevice;
targetDeviceId: string; targetDeviceId: string;
targetDeviceName: string;
executingSubjects: Array<Subject<void>>; executingSubjects: Array<Subject<void>>;
subscribed = false; subscribed = false;
@ -242,11 +244,22 @@ export class WidgetSubscription implements IWidgetSubscription {
this.callbacks.onRpcFailed = this.callbacks.onRpcFailed || (() => {}); this.callbacks.onRpcFailed = this.callbacks.onRpcFailed || (() => {});
this.callbacks.onRpcErrorCleared = this.callbacks.onRpcErrorCleared || (() => {}); this.callbacks.onRpcErrorCleared = this.callbacks.onRpcErrorCleared || (() => {});
this.targetDeviceAliasIds = options.targetDeviceAliasIds; this.targetDevice = options.targetDevice;
this.targetDeviceIds = options.targetDeviceIds; if (!targetDeviceValid(this.targetDevice)) {
if (options.targetDeviceAliasIds && options.targetDeviceAliasIds.length) {
this.targetDeviceAliasId = null; this.targetDevice = {
this.targetDeviceId = null; type: TargetDeviceType.entity,
entityAliasId: options.targetDeviceAliasIds[0]
};
} else if (options.targetDeviceIds && options.targetDeviceIds.length) {
this.targetDevice = {
type: TargetDeviceType.device,
entityAliasId: options.targetDeviceIds[0]
};
}
}
this.targetEntityId = null;
this.targetEntityName = null;
this.rpcRejection = null; this.rpcRejection = null;
this.rpcErrorText = null; this.rpcErrorText = null;
@ -280,13 +293,15 @@ export class WidgetSubscription implements IWidgetSubscription {
this.subscriptionTimewindow = null; this.subscriptionTimewindow = null;
this.loadingData = false; this.loadingData = false;
this.displayLegend = false; this.displayLegend = false;
this.initAlarmSubscription().subscribe(() => { this.initAlarmSubscription().subscribe({
subscriptionSubject.next(this); next:() => {
subscriptionSubject.complete(); subscriptionSubject.next(this);
}, subscriptionSubject.complete();
() => { },
subscriptionSubject.error(null); error: () => {
}); subscriptionSubject.error(null);
}}
);
} else { } else {
this.callbacks.onDataUpdated = this.callbacks.onDataUpdated || (() => {}); this.callbacks.onDataUpdated = this.callbacks.onDataUpdated || (() => {});
this.callbacks.onLatestDataUpdated = this.callbacks.onLatestDataUpdated || (() => {}); this.callbacks.onLatestDataUpdated = this.callbacks.onLatestDataUpdated || (() => {});
@ -299,7 +314,7 @@ export class WidgetSubscription implements IWidgetSubscription {
this.callbacks.legendDataUpdated = this.callbacks.legendDataUpdated || (() => {}); this.callbacks.legendDataUpdated = this.callbacks.legendDataUpdated || (() => {});
this.callbacks.timeWindowUpdated = this.callbacks.timeWindowUpdated || (() => {}); this.callbacks.timeWindowUpdated = this.callbacks.timeWindowUpdated || (() => {});
this.configuredDatasources = this.ctx.utils.validateDatasources(options.datasources); this.configuredDatasources = this.ctx.dashboardUtils.validateAndUpdateDatasources(options.datasources);
this.datasourcesOptional = options.datasourcesOptional; this.datasourcesOptional = options.datasourcesOptional;
this.entityDataListeners = []; this.entityDataListeners = [];
this.hasDataPageLink = options.hasDataPageLink; this.hasDataPageLink = options.hasDataPageLink;
@ -361,62 +376,60 @@ export class WidgetSubscription implements IWidgetSubscription {
this.legendConfig.showAvg === true || this.legendConfig.showAvg === true ||
this.legendConfig.showTotal === true || this.legendConfig.showTotal === true ||
this.legendConfig.showLatest === true); this.legendConfig.showLatest === true);
this.initDataSubscription().subscribe(() => { this.initDataSubscription().subscribe({
next:() => {
subscriptionSubject.next(this); subscriptionSubject.next(this);
subscriptionSubject.complete(); subscriptionSubject.complete();
}, },
(err) => { error: () => {
subscriptionSubject.error(err); subscriptionSubject.error(null);
}); }}
);
} }
} }
private initRpc(): Observable<any> { private initRpc(): Observable<any> {
const initRpcSubject = new ReplaySubject<void>(); const initRpcSubject = new ReplaySubject<void>();
if (this.targetDeviceAliasIds && this.targetDeviceAliasIds.length > 0) { this.ctx.aliasController.resolveSingleEntityInfoForTargetDevice(this.targetDevice).subscribe({
this.targetDeviceAliasId = this.targetDeviceAliasIds[0]; next: (entityInfo) => {
this.ctx.aliasController.resolveSingleEntityInfo(this.targetDeviceAliasId).subscribe( if (entityInfo?.id) {
(entityInfo) => { this.targetEntityId = {
if (entityInfo && entityInfo.entityType === EntityType.DEVICE) { id: entityInfo.id,
this.targetDeviceId = entityInfo.id; entityType: entityInfo.entityType
this.targetDeviceName = entityInfo.name; };
if (this.targetDeviceId) { this.targetEntityName = entityInfo.name;
this.rpcEnabled = true; }
if (entityInfo?.entityType === EntityType.DEVICE) {
this.targetDeviceId = entityInfo.id;
}
if (this.targetDeviceId) {
this.rpcEnabled = true;
} else {
this.rpcEnabled = this.ctx.utils.widgetEditMode;
if (!this.rpcEnabled) {
if (this.targetEntityId) {
const entityType =
this.ctx.translate.instant(entityTypeTranslations.get(this.targetEntityId.entityType).type);
this.rpcDisabledReason =
this.ctx.translate.instant('rpc.error.invalid-target-entity', {entityType});
} else { } else {
this.rpcEnabled = this.ctx.utils.widgetEditMode; this.rpcDisabledReason = this.ctx.translate.instant('rpc.error.target-device-is-not-set');
} }
this.hasResolvedData = this.rpcEnabled;
this.callbacks.rpcStateChanged(this);
initRpcSubject.next();
initRpcSubject.complete();
} else {
this.rpcEnabled = false;
this.callbacks.rpcStateChanged(this);
initRpcSubject.next();
initRpcSubject.complete();
} }
},
() => {
this.rpcEnabled = false;
this.callbacks.rpcStateChanged(this);
initRpcSubject.next();
initRpcSubject.complete();
} }
); this.hasResolvedData = true;
} else { this.callbacks.rpcStateChanged(this);
if (this.targetDeviceIds && this.targetDeviceIds.length > 0) { initRpcSubject.next();
this.targetDeviceId = this.targetDeviceIds[0]; initRpcSubject.complete();
} },
if (this.targetDeviceId) { error: () => {
this.rpcEnabled = true; this.rpcEnabled = false;
} else { this.rpcDisabledReason = this.ctx.translate.instant('rpc.error.failed-to-resolve-target-device');
this.rpcEnabled = this.ctx.utils.widgetEditMode; this.callbacks.rpcStateChanged(this);
initRpcSubject.next();
initRpcSubject.complete();
} }
this.hasResolvedData = true; });
this.callbacks.rpcStateChanged(this);
initRpcSubject.next();
initRpcSubject.complete();
}
return initRpcSubject.asObservable(); return initRpcSubject.asObservable();
} }
@ -430,17 +443,19 @@ export class WidgetSubscription implements IWidgetSubscription {
initAlarmSubscriptionSubject.complete(); initAlarmSubscriptionSubject.complete();
} else { } else {
this.ctx.aliasController.resolveAlarmSource(this.alarmSource).subscribe( this.ctx.aliasController.resolveAlarmSource(this.alarmSource).subscribe(
(alarmSource) => { {
this.alarmSource = alarmSource; next: (alarmSource) => {
if (alarmSource) { this.alarmSource = alarmSource;
this.hasResolvedData = true; if (alarmSource) {
this.hasResolvedData = true;
}
this.configureAlarmsData();
initAlarmSubscriptionSubject.next();
initAlarmSubscriptionSubject.complete();
},
error: (err) => {
initAlarmSubscriptionSubject.error(err);
} }
this.configureAlarmsData();
initAlarmSubscriptionSubject.next();
initAlarmSubscriptionSubject.complete();
},
(err) => {
initAlarmSubscriptionSubject.error(err);
} }
); );
} }
@ -467,18 +482,20 @@ export class WidgetSubscription implements IWidgetSubscription {
); );
} else { } else {
this.ctx.aliasController.resolveDatasources(this.configuredDatasources, this.singleEntity, this.pageSize).subscribe( this.ctx.aliasController.resolveDatasources(this.configuredDatasources, this.singleEntity, this.pageSize).subscribe(
(datasources) => { {
this.configuredDatasources = datasources; next: (datasources) => {
this.prepareDataSubscriptions().subscribe( this.configuredDatasources = datasources;
() => { this.prepareDataSubscriptions().subscribe(
initDataSubscriptionSubject.next(); () => {
initDataSubscriptionSubject.complete(); initDataSubscriptionSubject.next();
} initDataSubscriptionSubject.complete();
); }
}, );
(err) => { },
this.notifyDataLoaded(); error: (err) => {
initDataSubscriptionSubject.error(err); this.notifyDataLoaded();
initDataSubscriptionSubject.error(err);
}
} }
); );
} }
@ -573,12 +590,10 @@ export class WidgetSubscription implements IWidgetSubscription {
let entityLabel: string; let entityLabel: string;
let entityDescription: string; let entityDescription: string;
if (this.type === widgetType.rpc) { if (this.type === widgetType.rpc) {
if (this.targetDeviceId) { if (this.targetEntityId) {
entityId = { entityId = this.targetEntityId;
entityType: EntityType.DEVICE, entityName = this.targetEntityName;
id: this.targetDeviceId entityLabel = this.targetEntityName;
};
entityName = this.targetDeviceName;
} }
} else if (this.type === widgetType.alarm) { } else if (this.type === widgetType.alarm) {
if (this.alarmSource && this.alarmSource.entityType && this.alarmSource.entityId) { if (this.alarmSource && this.alarmSource.entityType && this.alarmSource.entityId) {
@ -806,9 +821,11 @@ export class WidgetSubscription implements IWidgetSubscription {
persistent?: boolean, persistentPollingInterval?: number, retries?: number, persistent?: boolean, persistentPollingInterval?: number, retries?: number,
additionalInfo?: any, requestUUID?: string): Observable<any> { additionalInfo?: any, requestUUID?: string): Observable<any> {
if (!this.rpcEnabled) { if (!this.rpcEnabled) {
return throwError(new Error('Rpc disabled!')); this.rpcErrorText = this.rpcDisabledReason;
this.rpcRejection = new Error(this.rpcErrorText);
return throwError(() => this.rpcRejection);
} else { } else {
if (this.rpcRejection && this.rpcRejection.status !== 504) { if (this.rpcRejection && (!(this.rpcRejection as any).status || (this.rpcRejection as HttpErrorResponse).status !== 504)) {
this.rpcRejection = null; this.rpcRejection = null;
this.rpcErrorText = null; this.rpcErrorText = null;
this.callbacks.onRpcErrorCleared(this); this.callbacks.onRpcErrorCleared(this);
@ -845,15 +862,17 @@ export class WidgetSubscription implements IWidgetSubscription {
this.ctx.deviceService.sendTwoWayRpcCommand(this.targetDeviceId, requestBody)).pipe( this.ctx.deviceService.sendTwoWayRpcCommand(this.targetDeviceId, requestBody)).pipe(
switchMap((response) => { switchMap((response) => {
if (persistent && persistentPollingInterval > 0) { if (persistent && persistentPollingInterval > 0) {
return timer(persistentPollingInterval / 2, persistentPollingInterval).pipe( const pollingInterval = Math.max(persistentPollingInterval, 1000);
const initialTimeout = timeout ? Math.min(timeout + 1000, pollingInterval) : pollingInterval;
return timer(initialTimeout, pollingInterval).pipe(
switchMap(() => this.ctx.deviceService.getPersistedRpc(response.rpcId, true)), switchMap(() => this.ctx.deviceService.getPersistedRpc(response.rpcId, true)),
filter(persistentRespons => filter(persistentRespons =>
persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED), persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED),
switchMap(persistentResponse => { switchMap(persistentResponse => {
if (persistentResponse.status === RpcStatus.TIMEOUT) { if ([RpcStatus.TIMEOUT, RpcStatus.EXPIRED].includes(persistentResponse.status)) {
return throwError({status: 504}); return throwError(() => ({status: 504}));
} else if (persistentResponse.status === RpcStatus.FAILED) { } else if (persistentResponse.status === RpcStatus.FAILED) {
return throwError({status: 502, statusText: persistentResponse.response.error}); return throwError(() => ({status: 502, statusText: persistentResponse.response.error}));
} else { } else {
return of(persistentResponse.response); return of(persistentResponse.response);
} }
@ -864,40 +883,43 @@ export class WidgetSubscription implements IWidgetSubscription {
return of(response); return of(response);
}) })
) )
.subscribe((responseBody) => { .subscribe({
this.rpcRejection = null; next: (responseBody) => {
this.rpcErrorText = null; this.rpcRejection = null;
const index = this.executingSubjects.indexOf(rpcSubject); this.rpcErrorText = null;
if (index >= 0) { const index = this.executingSubjects.indexOf(rpcSubject);
this.executingSubjects.splice( index, 1 ); if (index >= 0) {
} this.executingSubjects.splice( index, 1 );
this.executingRpcRequest = this.executingSubjects.length > 0; }
this.callbacks.onRpcSuccess(this); this.executingRpcRequest = this.executingSubjects.length > 0;
rpcSubject.next(responseBody); this.callbacks.onRpcSuccess(this);
rpcSubject.complete(); rpcSubject.next(responseBody);
}, rpcSubject.complete();
(rejection: HttpErrorResponse) => { },
const index = this.executingSubjects.indexOf(rpcSubject); error: (rejection: HttpErrorResponse) => {
if (index >= 0) { const index = this.executingSubjects.indexOf(rpcSubject);
this.executingSubjects.splice( index, 1 ); if (index >= 0) {
} this.executingSubjects.splice( index, 1 );
this.executingRpcRequest = this.executingSubjects.length > 0; }
this.callbacks.rpcStateChanged(this); this.executingRpcRequest = this.executingSubjects.length > 0;
if (!this.executingRpcRequest || rejection.status === 504) { this.callbacks.rpcStateChanged(this);
this.rpcRejection = rejection; if (!this.executingRpcRequest || rejection.status === 504) {
if (rejection.status === 504) { this.rpcRejection = rejection;
this.rpcErrorText = 'Request Timeout.'; if (rejection.status === 504) {
} else { this.rpcErrorText = this.ctx.translate.instant('rpc.error.request-timeout');
this.rpcErrorText = 'Error : ' + rejection.status + ' - ' + rejection.statusText; } else {
const error = parseHttpErrorMessage(rejection, this.ctx.translate); this.rpcErrorText = this.ctx.translate.instant('rpc.error.rpc-http-error',
if (error) { {status: rejection.status, statusText: rejection.statusText});
this.rpcErrorText += '</br>'; const error = parseHttpErrorMessage(rejection, this.ctx.translate);
this.rpcErrorText += error.message; if (error) {
this.rpcErrorText += '</br>';
this.rpcErrorText += error.message;
}
} }
this.callbacks.onRpcFailed(this);
} }
this.callbacks.onRpcFailed(this); rpcSubject.error(rejection);
} }
rpcSubject.error(rejection);
}); });
} }
return rpcSubject.asObservable(); return rpcSubject.asObservable();
@ -940,7 +962,7 @@ export class WidgetSubscription implements IWidgetSubscription {
subscribeAllForPaginatedData(pageLink: EntityDataPageLink, subscribeAllForPaginatedData(pageLink: EntityDataPageLink,
keyFilters: KeyFilter[]): Observable<any> { keyFilters: KeyFilter[]): Observable<any> {
const observables: Observable<any>[] = []; const observables: Observable<any>[] = [];
this.configuredDatasources.forEach((datasource, datasourceIndex) => { this.configuredDatasources.forEach((_datasource, datasourceIndex) => {
observables.push(this.subscribeForPaginatedData(datasourceIndex, pageLink, keyFilters)); observables.push(this.subscribeForPaginatedData(datasourceIndex, pageLink, keyFilters));
}); });
if (observables.length) { if (observables.length) {
@ -1091,7 +1113,8 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
private checkRpcTarget(aliasIds: Array<string>): boolean { private checkRpcTarget(aliasIds: Array<string>): boolean {
return aliasIds.indexOf(this.targetDeviceAliasId) > -1; return this.targetDevice?.type === TargetDeviceType.entity &&
aliasIds.indexOf(this.targetDevice.entityAliasId) > -1;
} }
private checkAlarmSource(aliasIds: Array<string>): boolean { private checkAlarmSource(aliasIds: Array<string>): boolean {
@ -1120,16 +1143,18 @@ export class WidgetSubscription implements IWidgetSubscription {
this.updateAlarmDataSubscription(); this.updateAlarmDataSubscription();
} else { } else {
this.ctx.aliasController.resolveAlarmSource(this.alarmSource).subscribe( this.ctx.aliasController.resolveAlarmSource(this.alarmSource).subscribe(
(alarmSource) => { {
this.alarmSource = alarmSource; next: (alarmSource) => {
if (alarmSource) { this.alarmSource = alarmSource;
this.hasResolvedData = true; if (alarmSource) {
this.hasResolvedData = true;
}
this.configureAlarmsData();
this.updateAlarmDataSubscription();
},
error: () => {
this.notifyDataLoaded();
} }
this.configureAlarmsData();
this.updateAlarmDataSubscription();
},
() => {
this.notifyDataLoaded();
} }
); );
} }
@ -1184,7 +1209,7 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
private updateDataSubscriptions() { private updateDataSubscriptions() {
this.configuredDatasources = this.ctx.utils.validateDatasources(this.options.datasources); this.configuredDatasources = this.ctx.dashboardUtils.validateAndUpdateDatasources(this.options.datasources);
if (!this.ctx.aliasController) { if (!this.ctx.aliasController) {
this.configuredDatasources = deepClone(this.configuredDatasources); this.configuredDatasources = deepClone(this.configuredDatasources);
this.hasResolvedData = true; this.hasResolvedData = true;
@ -1195,16 +1220,18 @@ export class WidgetSubscription implements IWidgetSubscription {
); );
} else { } else {
this.ctx.aliasController.resolveDatasources(this.configuredDatasources, this.singleEntity, this.pageSize).subscribe( this.ctx.aliasController.resolveDatasources(this.configuredDatasources, this.singleEntity, this.pageSize).subscribe(
(datasources) => { {
this.configuredDatasources = datasources; next: (datasources) => {
this.prepareDataSubscriptions().subscribe( this.configuredDatasources = datasources;
() => { this.prepareDataSubscriptions().subscribe(
this.updatePaginatedDataSubscriptions(); () => {
} this.updatePaginatedDataSubscriptions();
); }
}, );
() => { },
this.notifyDataLoaded(); error: () => {
this.notifyDataLoaded();
}
} }
); );
} }
@ -1320,7 +1347,7 @@ export class WidgetSubscription implements IWidgetSubscription {
private dataLoaded(pageData: PageData<EntityData>, private dataLoaded(pageData: PageData<EntityData>,
data: Array<Array<DataSetHolder>>, data: Array<Array<DataSetHolder>>,
datasourceIndex: number, pageLink: EntityDataPageLink, isUpdate: boolean) { datasourceIndex: number, _pageLink: EntityDataPageLink, isUpdate: boolean) {
const datasource = this.configuredDatasources[datasourceIndex]; const datasource = this.configuredDatasources[datasourceIndex];
datasource.dataReceived = true; datasource.dataReceived = true;
const datasources = pageData.data.map((entityData, index) => const datasources = pageData.data.map((entityData, index) =>
@ -1405,7 +1432,7 @@ export class WidgetSubscription implements IWidgetSubscription {
}); });
if (datasource.latestDataKeys && datasource.latestDataKeys.length) { if (datasource.latestDataKeys && datasource.latestDataKeys.length) {
this.hasLatestData = true; this.hasLatestData = true;
datasource.latestDataKeys.forEach((dataKey, currentLatestDataKeyIndex) => { datasource.latestDataKeys.forEach((_dataKey, currentLatestDataKeyIndex) => {
const currentDataKeyIndex = datasource.dataKeys.length + currentLatestDataKeyIndex; const currentDataKeyIndex = datasource.dataKeys.length + currentLatestDataKeyIndex;
const datasourceData = datasourceDataPage.data[currentDatasourceIndex][currentDataKeyIndex]; const datasourceData = datasourceDataPage.data[currentDatasourceIndex][currentDataKeyIndex];
this.latestData.push(datasourceData); this.latestData.push(datasourceData);
@ -1610,7 +1637,7 @@ export class WidgetSubscription implements IWidgetSubscription {
this.onDataUpdated(); this.onDataUpdated();
} }
private alarmsUpdated(updated: Array<AlarmData>, alarms: PageData<AlarmData>) { private alarmsUpdated(_updated: Array<AlarmData>, alarms: PageData<AlarmData>) {
this.alarmsLoaded(alarms, 0, 0); this.alarmsLoaded(alarms, 0, 0);
} }
@ -1641,15 +1668,17 @@ export class WidgetSubscription implements IWidgetSubscription {
const loadSubject = new ReplaySubject<void>(1); const loadSubject = new ReplaySubject<void>(1);
if (this.ctx.getServerTimeDiff && this.timeWindow) { if (this.ctx.getServerTimeDiff && this.timeWindow) {
this.ctx.getServerTimeDiff().subscribe( this.ctx.getServerTimeDiff().subscribe(
(stDiff) => { {
this.timeWindow.stDiff = stDiff; next: (stDiff) => {
loadSubject.next(); this.timeWindow.stDiff = stDiff;
loadSubject.complete(); loadSubject.next();
}, loadSubject.complete();
() => { },
this.timeWindow.stDiff = 0; error: () => {
loadSubject.next(); this.timeWindow.stDiff = 0;
loadSubject.complete(); loadSubject.next();
loadSubject.complete();
}
} }
); );
} else { } else {

4
ui-ngx/src/app/core/http/device.service.ts

@ -148,11 +148,11 @@ export class DeviceService {
} }
public sendOneWayRpcCommand(deviceId: string, requestBody: any, config?: RequestConfig): Observable<any> { public sendOneWayRpcCommand(deviceId: string, requestBody: any, config?: RequestConfig): Observable<any> {
return this.http.post<Device>(`/api/rpc/oneway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config)); return this.http.post<any>(`/api/rpc/oneway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config));
} }
public sendTwoWayRpcCommand(deviceId: string, requestBody: any, config?: RequestConfig): Observable<any> { public sendTwoWayRpcCommand(deviceId: string, requestBody: any, config?: RequestConfig): Observable<any> {
return this.http.post<Device>(`/api/rpc/twoway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config)); return this.http.post<any>(`/api/rpc/twoway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config));
} }
public getPersistedRpc(rpcId: string, fullResponse = false, config?: RequestConfig): Observable<PersistentRpc> { public getPersistedRpc(rpcId: string, fullResponse = false, config?: RequestConfig): Observable<PersistentRpc> {

19
ui-ngx/src/app/core/http/entity.service.ts

@ -501,9 +501,13 @@ export class EntityService {
} }
public findEntityKeysByQuery(query: EntityDataQuery, attributes = true, timeseries = true, public findEntityKeysByQuery(query: EntityDataQuery, attributes = true, timeseries = true,
config?: RequestConfig): Observable<EntitiesKeysByQuery> { scope?: AttributeScope, config?: RequestConfig): Observable<EntitiesKeysByQuery> {
let url = `/api/entitiesQuery/find/keys?attributes=${attributes}&timeseries=${timeseries}`;
if (scope) {
url += `&scope=${scope}`;
}
return this.http.post<EntitiesKeysByQuery>( return this.http.post<EntitiesKeysByQuery>(
`/api/entitiesQuery/find/keys?attributes=${attributes}&timeseries=${timeseries}`, url,
query, defaultHttpOptionsFromConfig(config)); query, defaultHttpOptionsFromConfig(config));
} }
@ -831,7 +835,14 @@ export class EntityService {
} }
public getEntityKeysByEntityFilter(filter: EntityFilter, types: DataKeyType[], public getEntityKeysByEntityFilter(filter: EntityFilter, types: DataKeyType[],
entityTypes?: EntityType[], config?: RequestConfig): Observable<Array<DataKey>> { entityTypes?: EntityType[],
config?: RequestConfig): Observable<Array<DataKey>> {
return this.getEntityKeysByEntityFilterAndScope(filter, types, entityTypes, null, config);
}
public getEntityKeysByEntityFilterAndScope(filter: EntityFilter, types: DataKeyType[],
entityTypes?: EntityType[], scope?: AttributeScope,
config?: RequestConfig): Observable<Array<DataKey>> {
if (!types.length) { if (!types.length) {
return of([]); return of([]);
} }
@ -842,7 +853,7 @@ export class EntityService {
pageLink: createDefaultEntityDataPageLink(100), pageLink: createDefaultEntityDataPageLink(100),
}; };
entitiesKeysByQuery$ = this.findEntityKeysByQuery(dataQuery, types.includes(DataKeyType.attribute), entitiesKeysByQuery$ = this.findEntityKeysByQuery(dataQuery, types.includes(DataKeyType.attribute),
types.includes(DataKeyType.timeseries), config); types.includes(DataKeyType.timeseries), scope, config);
} else { } else {
entitiesKeysByQuery$ = of({ entitiesKeysByQuery$ = of({
attribute: [], attribute: [],

35
ui-ngx/src/app/core/interceptors/global-http-interceptor.ts

@ -31,7 +31,7 @@ import { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { parseHttpErrorMessage } from '@core/utils'; import { parseHttpErrorMessage } from '@core/utils';
let tmpHeaders = {}; const tmpHeaders = {};
@Injectable() @Injectable()
export class GlobalHttpInterceptor implements HttpInterceptor { export class GlobalHttpInterceptor implements HttpInterceptor {
@ -85,7 +85,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
if (newReq) { if (newReq) {
return this.handleRequest(newReq, next); return this.handleRequest(newReq, next);
} else { } else {
return throwError(new Error('Could not get JWT token from store.')); return throwError(() => new Error('Could not get JWT token from store.'));
} }
} }
@ -136,37 +136,34 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
const errorMessageWithTimeout = parseHttpErrorMessage(errorResponse, this.translate, req.responseType); const errorMessageWithTimeout = parseHttpErrorMessage(errorResponse, this.translate, req.responseType);
this.showError(errorMessageWithTimeout.message, errorMessageWithTimeout.timeout); this.showError(errorMessageWithTimeout.message, errorMessageWithTimeout.timeout);
} }
return throwError(errorResponse); return throwError(() => errorResponse);
} }
private retryRequest(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { private retryRequest(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const thisTimeout = 1000 + Math.random() * 3000; const thisTimeout = 1000 + Math.random() * 3000;
return of(null).pipe( return of(null).pipe(
delay(thisTimeout), delay(thisTimeout),
mergeMap(() => { mergeMap(() => this.jwtIntercept(req, next)
return this.jwtIntercept(req, next);
}
)); ));
} }
private refreshTokenAndRetry(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { private refreshTokenAndRetry(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return this.authService.refreshJwtToken().pipe(switchMap(() => { return this.authService.refreshJwtToken().pipe(
return this.jwtIntercept(req, next); catchError((err: Error) => {
}), this.authService.logout(true, true);
catchError((err: Error) => { const message = err ? err.message : 'Unauthorized!';
this.authService.logout(true, true); return this.handleResponseError(req, next, new HttpErrorResponse({error: {message, timeout: 200}, status: 401}));
const message = err ? err.message : 'Unauthorized!'; }),
return this.handleResponseError(req, next, new HttpErrorResponse({error: {message, timeout: 200}, status: 401})); switchMap(() => this.jwtIntercept(req, next)),
})); );
} }
private updateAuthorizationHeader(req: HttpRequest<any>): HttpRequest<any> { private updateAuthorizationHeader(req: HttpRequest<any>): HttpRequest<any> {
const jwtToken = AuthService.getJwtToken(); const jwtToken = AuthService.getJwtToken();
if (jwtToken) { if (jwtToken) {
tmpHeaders[this.AUTH_HEADER_NAME] = `${this.AUTH_SCHEME}${jwtToken}`;
req = req.clone({ req = req.clone({
setHeaders: (tmpHeaders = {}, setHeaders: tmpHeaders
tmpHeaders[this.AUTH_HEADER_NAME] = '' + this.AUTH_SCHEME + jwtToken,
tmpHeaders)
}); });
return req; return req;
} else { } else {
@ -174,7 +171,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
} }
} }
private isInternalUrlPrefix(url): boolean { private isInternalUrlPrefix(url: string): boolean {
for (const index in this.internalUrlPrefixes) { for (const index in this.internalUrlPrefixes) {
if (url.startsWith(this.internalUrlPrefixes[index])) { if (url.startsWith(this.internalUrlPrefixes[index])) {
return true; return true;
@ -183,7 +180,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
return false; return false;
} }
private isTokenBasedAuthEntryPoint(url): boolean { private isTokenBasedAuthEntryPoint(url: string): boolean {
return url.startsWith('/api/') && return url.startsWith('/api/') &&
!url.startsWith(Constants.entryPoints.login) && !url.startsWith(Constants.entryPoints.login) &&
!url.startsWith(Constants.entryPoints.tokenRefresh) && !url.startsWith(Constants.entryPoints.tokenRefresh) &&

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

@ -29,20 +29,15 @@ import {
GridSettings, GridSettings,
WidgetLayout WidgetLayout
} from '@shared/models/dashboard.models'; } from '@shared/models/dashboard.models';
import { import { deepClone, isDefined, isDefinedAndNotNull, isNotEmptyStr, isString, isUndefined } from '@core/utils';
deepClone,
isDefined,
isDefinedAndNotNull,
isEmptyStr,
isNotEmptyStr,
isString,
isUndefined
} from '@core/utils';
import { import {
Datasource, Datasource,
datasourcesHasOnlyComparisonAggregation, datasourcesHasOnlyComparisonAggregation,
DatasourceType, DatasourceType,
defaultLegendConfig, isValidWidgetFullFqn, defaultLegendConfig,
isValidWidgetFullFqn,
TargetDevice,
TargetDeviceType,
Widget, Widget,
WidgetConfig, WidgetConfig,
WidgetConfigMode, WidgetConfigMode,
@ -124,7 +119,7 @@ export class DashboardUtilsService {
} }
} }
const datasourcesByAliasId: {[aliasId: string]: Array<Datasource>} = {}; const datasourcesByAliasId: {[aliasId: string]: Array<Datasource>} = {};
const targetDevicesByAliasId: {[aliasId: string]: Array<Array<string>>} = {}; const targetDevicesByAliasId: {[aliasId: string]: Array<TargetDevice>} = {};
for (const widgetId of Object.keys(dashboard.configuration.widgets)) { for (const widgetId of Object.keys(dashboard.configuration.widgets)) {
const widget = dashboard.configuration.widgets[widgetId]; const widget = dashboard.configuration.widgets[widgetId];
const datasources = widget.type === widgetType.alarm ? [widget.config.alarmSource] : widget.config.datasources; const datasources = widget.type === widgetType.alarm ? [widget.config.alarmSource] : widget.config.datasources;
@ -139,14 +134,14 @@ export class DashboardUtilsService {
aliasDatasources.push(datasource); aliasDatasources.push(datasource);
} }
}); });
if (widget.config.targetDeviceAliasIds && widget.config.targetDeviceAliasIds.length) { if (widget.config.targetDevice?.type === TargetDeviceType.entity && widget.config.targetDevice.entityAliasId) {
const aliasId = widget.config.targetDeviceAliasIds[0]; const aliasId = widget.config.targetDevice.entityAliasId;
let targetDeviceAliasIdsList = targetDevicesByAliasId[aliasId]; let targetDevicesList = targetDevicesByAliasId[aliasId];
if (!targetDeviceAliasIdsList) { if (!targetDevicesList) {
targetDeviceAliasIdsList = []; targetDevicesList = [];
targetDevicesByAliasId[aliasId] = targetDeviceAliasIdsList; targetDevicesByAliasId[aliasId] = targetDevicesList;
} }
targetDeviceAliasIdsList.push(widget.config.targetDeviceAliasIds); targetDevicesList.push(widget.config.targetDevice);
} }
} }
@ -281,21 +276,23 @@ export class DashboardUtilsService {
if (!widgetConfig) { if (!widgetConfig) {
widgetConfig = {}; widgetConfig = {};
} }
if (!widgetConfig.datasources) { widgetConfig.datasources = this.validateAndUpdateDatasources(widgetConfig.datasources);
widgetConfig.datasources = [];
}
widgetConfig.datasources.forEach((datasource) => {
if (datasource.deviceAliasId) {
datasource.type = DatasourceType.entity;
datasource.entityAliasId = datasource.deviceAliasId;
delete datasource.deviceAliasId;
}
});
if (type === widgetType.latest) { if (type === widgetType.latest) {
const onlyHistoryTimewindow = datasourcesHasOnlyComparisonAggregation(widgetConfig.datasources); const onlyHistoryTimewindow = datasourcesHasOnlyComparisonAggregation(widgetConfig.datasources);
widgetConfig.timewindow = initModelFromDefaultTimewindow(widgetConfig.timewindow, true, onlyHistoryTimewindow, this.timeService); widgetConfig.timewindow = initModelFromDefaultTimewindow(widgetConfig.timewindow, true, onlyHistoryTimewindow, this.timeService);
} } else if (type === widgetType.rpc) {
if (type === widgetType.alarm) { if (widgetConfig.targetDeviceAliasIds && widgetConfig.targetDeviceAliasIds.length) {
widgetConfig.targetDevice = {
type: TargetDeviceType.entity,
entityAliasId: widgetConfig.targetDeviceAliasIds[0]
};
delete widgetConfig.targetDeviceAliasIds;
} else if (!widgetConfig.targetDevice) {
widgetConfig.targetDevice = {
type: TargetDeviceType.device
};
}
} else if (type === widgetType.alarm) {
if (!widgetConfig.alarmFilterConfig) { if (!widgetConfig.alarmFilterConfig) {
widgetConfig.alarmFilterConfig = {}; widgetConfig.alarmFilterConfig = {};
const alarmFilterConfig = widgetConfig.alarmFilterConfig; const alarmFilterConfig = widgetConfig.alarmFilterConfig;
@ -335,6 +332,35 @@ export class DashboardUtilsService {
return widgetConfig; return widgetConfig;
} }
public validateAndUpdateDatasources(datasources?: Datasource[]): Datasource[] {
if (!datasources) {
datasources = [];
}
datasources.forEach((datasource) => {
if (datasource.deviceAliasId) {
datasource.type = DatasourceType.entity;
datasource.entityAliasId = datasource.deviceAliasId;
delete datasource.deviceAliasId;
}
if (datasource.deviceName) {
datasource.entityName = datasource.deviceName;
delete datasource.deviceName;
}
if (datasource.type === DatasourceType.entity && datasource.entityId) {
datasource.name = datasource.entityName;
}
if (!datasource.dataKeys) {
datasource.dataKeys = [];
}
datasource.dataKeys.forEach(dataKey => {
if (isUndefined(dataKey.label)) {
dataKey.label = dataKey.name;
}
});
});
return datasources;
}
public createDefaultLayoutData(): DashboardLayout { public createDefaultLayoutData(): DashboardLayout {
return { return {
widgets: {}, widgets: {},
@ -689,7 +715,7 @@ export class DashboardUtilsService {
private validateAndUpdateEntityAliases(configuration: DashboardConfiguration, private validateAndUpdateEntityAliases(configuration: DashboardConfiguration,
datasourcesByAliasId: {[aliasId: string]: Array<Datasource>}, datasourcesByAliasId: {[aliasId: string]: Array<Datasource>},
targetDevicesByAliasId: {[aliasId: string]: Array<Array<string>>}): DashboardConfiguration { targetDevicesByAliasId: {[aliasId: string]: Array<TargetDevice>}): DashboardConfiguration {
let entityAlias: EntityAlias; let entityAlias: EntityAlias;
if (isUndefined(configuration.entityAliases)) { if (isUndefined(configuration.entityAliases)) {
configuration.entityAliases = {}; configuration.entityAliases = {};
@ -719,7 +745,7 @@ export class DashboardUtilsService {
private validateAndUpdateDeviceAlias(aliasId: string, private validateAndUpdateDeviceAlias(aliasId: string,
deviceAlias: any, deviceAlias: any,
datasourcesByAliasId: {[aliasId: string]: Array<Datasource>}, datasourcesByAliasId: {[aliasId: string]: Array<Datasource>},
targetDevicesByAliasId: {[aliasId: string]: Array<Array<string>>}): EntityAlias { targetDevicesByAliasId: {[aliasId: string]: Array<TargetDevice>}): EntityAlias {
aliasId = this.validateAliasId(aliasId, datasourcesByAliasId, targetDevicesByAliasId); aliasId = this.validateAliasId(aliasId, datasourcesByAliasId, targetDevicesByAliasId);
const alias = deviceAlias.alias; const alias = deviceAlias.alias;
const entityAlias: EntityAlias = { const entityAlias: EntityAlias = {
@ -748,7 +774,7 @@ export class DashboardUtilsService {
private validateAndUpdateEntityAlias(aliasId: string, entityAlias: EntityAlias, private validateAndUpdateEntityAlias(aliasId: string, entityAlias: EntityAlias,
datasourcesByAliasId: {[aliasId: string]: Array<Datasource>}, datasourcesByAliasId: {[aliasId: string]: Array<Datasource>},
targetDevicesByAliasId: {[aliasId: string]: Array<Array<string>>}): EntityAlias { targetDevicesByAliasId: {[aliasId: string]: Array<TargetDevice>}): EntityAlias {
entityAlias.id = this.validateAliasId(aliasId, datasourcesByAliasId, targetDevicesByAliasId); entityAlias.id = this.validateAliasId(aliasId, datasourcesByAliasId, targetDevicesByAliasId);
if (!entityAlias.filter) { if (!entityAlias.filter) {
entityAlias.filter = { entityAlias.filter = {
@ -810,7 +836,7 @@ export class DashboardUtilsService {
private validateAliasId(aliasId: string, private validateAliasId(aliasId: string,
datasourcesByAliasId: {[aliasId: string]: Array<Datasource>}, datasourcesByAliasId: {[aliasId: string]: Array<Datasource>},
targetDevicesByAliasId: {[aliasId: string]: Array<Array<string>>}): string { targetDevicesByAliasId: {[aliasId: string]: Array<TargetDevice>}): string {
if (!aliasId || !isString(aliasId) || aliasId.length !== 36) { if (!aliasId || !isString(aliasId) || aliasId.length !== 36) {
const newAliasId = this.utils.guid(); const newAliasId = this.utils.guid();
const aliasDatasources = datasourcesByAliasId[aliasId]; const aliasDatasources = datasourcesByAliasId[aliasId];
@ -821,11 +847,11 @@ export class DashboardUtilsService {
} }
); );
} }
const targetDeviceAliasIdsList = targetDevicesByAliasId[aliasId]; const targetDevicesList = targetDevicesByAliasId[aliasId];
if (targetDeviceAliasIdsList) { if (targetDevicesList) {
targetDeviceAliasIdsList.forEach( targetDevicesList.forEach(
(targetDeviceAliasIds) => { (targetDevice) => {
targetDeviceAliasIds[0] = newAliasId; targetDevice.entityAliasId = newAliasId;
} }
); );
} }

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

@ -20,6 +20,7 @@ import { AliasesInfo, EntityAlias, EntityAliases, EntityAliasInfo } from '@share
import { import {
Datasource, Datasource,
DatasourceType, DatasourceType,
TargetDeviceType,
Widget, Widget,
WidgetPosition, WidgetPosition,
WidgetSize, WidgetSize,
@ -87,7 +88,7 @@ export class ItemBufferService {
public prepareWidgetItem(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget): WidgetItem { public prepareWidgetItem(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget): WidgetItem {
const aliasesInfo: AliasesInfo = { const aliasesInfo: AliasesInfo = {
datasourceAliases: {}, datasourceAliases: {},
targetDeviceAliases: {} targetDeviceAlias: null
}; };
const filtersInfo: FiltersInfo = { const filtersInfo: FiltersInfo = {
datasourceFilters: {} datasourceFilters: {}
@ -111,15 +112,11 @@ export class ItemBufferService {
} }
} }
} }
if (widget.config.targetDeviceAliasIds) { if (widget.config.targetDevice?.type === TargetDeviceType.entity && widget.config.targetDevice.entityAliasId) {
for (let i = 0; i < widget.config.targetDeviceAliasIds.length; i++) { const targetDeviceAliasId = widget.config.targetDevice.entityAliasId;
const targetDeviceAliasId = widget.config.targetDeviceAliasIds[i]; entityAlias = dashboard.configuration.entityAliases[targetDeviceAliasId];
if (targetDeviceAliasId) { if (entityAlias) {
entityAlias = dashboard.configuration.entityAliases[targetDeviceAliasId]; aliasesInfo.targetDeviceAlias = this.prepareAliasInfo(entityAlias);
if (entityAlias) {
aliasesInfo.targetDeviceAliases[i] = this.prepareAliasInfo(entityAlias);
}
}
} }
} }
} }
@ -456,11 +453,15 @@ export class ItemBufferService {
widget.config.datasources[datasourceIndex].entityAliasId = newAliasId; widget.config.datasources[datasourceIndex].entityAliasId = newAliasId;
} }
} }
for (const targetDeviceAliasIndexStr of Object.keys(aliasesInfo.targetDeviceAliases)) { if (aliasesInfo.targetDeviceAlias) {
const targetDeviceAliasIndex = Number(targetDeviceAliasIndexStr); aliasInfo = aliasesInfo.targetDeviceAlias;
aliasInfo = aliasesInfo.targetDeviceAliases[targetDeviceAliasIndex];
newAliasId = this.getEntityAliasId(entityAliases, aliasInfo); newAliasId = this.getEntityAliasId(entityAliases, aliasInfo);
widget.config.targetDeviceAliasIds[targetDeviceAliasIndex] = newAliasId; if (widget.config.targetDevice?.type !== TargetDeviceType.entity) {
widget.config.targetDevice = {
type: TargetDeviceType.entity
};
}
widget.config.targetDevice.entityAliasId = newAliasId;
} }
return entityAliases; return entityAliases;
} }

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

@ -294,32 +294,6 @@ export class UtilsService {
return guid(); return guid();
} }
public validateDatasources(datasources: Array<Datasource>): Array<Datasource> {
datasources.forEach((datasource) => {
if (datasource.type === DatasourceType.device) {
if (datasource.deviceAliasId) {
datasource.type = DatasourceType.entity;
datasource.entityAliasId = datasource.deviceAliasId;
}
if (datasource.deviceName) {
datasource.entityName = datasource.deviceName;
}
}
if (datasource.type === DatasourceType.entity && datasource.entityId) {
datasource.name = datasource.entityName;
}
if (!datasource.dataKeys) {
datasource.dataKeys = [];
}
datasource.dataKeys.forEach(dataKey => {
if (isUndefined(dataKey.label)) {
dataKey.label = dataKey.name;
}
});
});
return datasources;
}
public getMaterialColor(index: number) { public getMaterialColor(index: number) {
const colorIndex = index % materialColors.length; const colorIndex = index % materialColors.length;
return materialColors[colorIndex].value; return materialColors[colorIndex].value;

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

@ -24,6 +24,7 @@ import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-t
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { serverErrorCodesTranslations } from '@shared/models/constants'; import { serverErrorCodesTranslations } from '@shared/models/constants';
import { SubscriptionEntityInfo } from '@core/api/widget-api.models';
const varsRegex = /\${([^}]*)}/g; const varsRegex = /\${([^}]*)}/g;
@ -426,6 +427,33 @@ export const createLabelFromDatasource = (datasource: Datasource, pattern: strin
return label; return label;
}; };
export const createLabelFromSubscriptionEntityInfo = (entityInfo: SubscriptionEntityInfo, pattern: string): string => {
let label = pattern;
if (!entityInfo) {
return label;
}
let match = varsRegex.exec(pattern);
while (match !== null) {
const variable = match[0];
const variableName = match[1];
if (variableName === 'dsName') {
label = label.replace(variable, entityInfo.entityName);
} else if (variableName === 'entityName') {
label = label.replace(variable, entityInfo.entityName);
} else if (variableName === 'deviceName') {
label = label.replace(variable, entityInfo.entityName);
} else if (variableName === 'entityLabel') {
label = label.replace(variable, entityInfo.entityLabel || entityInfo.entityName);
} else if (variableName === 'aliasName') {
label = label.replace(variable, entityInfo.entityName);
} else if (variableName === 'entityDescription') {
label = label.replace(variable, entityInfo.entityDescription);
}
match = varsRegex.exec(pattern);
}
return label;
};
export const hasDatasourceLabelsVariables = (pattern: string): boolean => varsRegex.test(pattern) !== null; export const hasDatasourceLabelsVariables = (pattern: string): boolean => varsRegex.test(pattern) !== null;
export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number): FormattedData[] { export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number): FormattedData[] {

12
ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts

@ -32,14 +32,14 @@ import {
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component'; import { DialogComponent } from '@app/shared/components/dialog.component';
import { EntityAlias, EntityAliases, EntityAliasFilter } from '@shared/models/alias.models'; import { EntityAlias, EntityAliases, EntityAliasFilter } from '@shared/models/alias.models';
import { DatasourceType, Widget, widgetType } from '@shared/models/widget.models'; import { DatasourceType, TargetDeviceType, Widget, widgetType } from '@shared/models/widget.models';
import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; import { AliasEntityType, EntityType } from '@shared/models/entity-type.models';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { ActionNotificationShow } from '@core/notification/notification.actions'; import { ActionNotificationShow } from '@core/notification/notification.actions';
import { DialogService } from '@core/services/dialog.service'; import { DialogService } from '@core/services/dialog.service';
import { deepClone, isUndefined } from '@core/utils'; import { deepClone, isUndefined } from '@core/utils';
import { EntityAliasDialogComponent, EntityAliasDialogData } from './entity-alias-dialog.component'; import { EntityAliasDialogComponent, EntityAliasDialogData } from './entity-alias-dialog.component';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
export interface EntityAliasesDialogData { export interface EntityAliasesDialogData {
entityAliases: EntityAliases; entityAliases: EntityAliases;
@ -77,7 +77,7 @@ export class EntityAliasesDialogComponent extends DialogComponent<EntityAliasesD
@SkipSelf() private errorStateMatcher: ErrorStateMatcher, @SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<EntityAliasesDialogComponent, EntityAliases>, public dialogRef: MatDialogRef<EntityAliasesDialogComponent, EntityAliases>,
private fb: UntypedFormBuilder, private fb: UntypedFormBuilder,
private utils: UtilsService, private dashboardUtils: DashboardUtilsService,
private translate: TranslateService, private translate: TranslateService,
private dialogs: DialogService, private dialogs: DialogService,
private dialog: MatDialog) { private dialog: MatDialog) {
@ -97,15 +97,15 @@ export class EntityAliasesDialogComponent extends DialogComponent<EntityAliasesD
} else { } else {
this.data.widgets.forEach((widget) => { this.data.widgets.forEach((widget) => {
if (widget.type === widgetType.rpc) { if (widget.type === widgetType.rpc) {
if (widget.config.targetDeviceAliasIds && widget.config.targetDeviceAliasIds.length > 0) { if (widget.config.targetDevice?.type === TargetDeviceType.entity && widget.config.targetDevice.entityAliasId) {
this.addWidgetTitleToWidgetsMap(widget.config.targetDeviceAliasIds[0], widget.config.title); this.addWidgetTitleToWidgetsMap(widget.config.targetDevice.entityAliasId, widget.config.title);
} }
} else if (widget.type === widgetType.alarm) { } else if (widget.type === widgetType.alarm) {
if (widget.config.alarmSource) { if (widget.config.alarmSource) {
this.addWidgetTitleToWidgetsMap(widget.config.alarmSource.entityAliasId, widget.config.title); this.addWidgetTitleToWidgetsMap(widget.config.alarmSource.entityAliasId, widget.config.title);
} }
} else { } else {
const datasources = this.utils.validateDatasources(widget.config.datasources); const datasources = this.dashboardUtils.validateAndUpdateDatasources(widget.config.datasources);
datasources.forEach((datasource) => { datasources.forEach((datasource) => {
if ([DatasourceType.entity, DatasourceType.entityCount, DatasourceType.alarmCount].includes(datasource.type) if ([DatasourceType.entity, DatasourceType.entityCount, DatasourceType.alarmCount].includes(datasource.type)
&& datasource.entityAliasId) { && datasource.entityAliasId) {

2
ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<form [formGroup]="attributeFormGroup" (ngSubmit)="add()" style="min-width: 400px;"> <form [formGroup]="attributeFormGroup" (ngSubmit)="add()" style="min-width: 420px; max-width: 420px;">
<mat-toolbar color="primary"> <mat-toolbar color="primary">
<h2>{{ (isTelemetry ? 'attribute.add-telemetry' : 'attribute.add') | translate }}</h2> <h2>{{ (isTelemetry ? 'attribute.add-telemetry' : 'attribute.add') | translate }}</h2>
<span fxFlex></span> <span fxFlex></span>

7
ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts

@ -126,9 +126,8 @@ export class AddWidgetToDashboardDialogComponent extends
mergeMap((dashboard) => { mergeMap((dashboard) => {
dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard); dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard);
return this.selectTargetState(dashboard).pipe( return this.selectTargetState(dashboard).pipe(
mergeMap((targetState) => { mergeMap((targetState) =>
return forkJoin([of(dashboard), of(targetState), this.selectTargetLayout(dashboard, targetState)]); forkJoin([of(dashboard), of(targetState), this.selectTargetLayout(dashboard, targetState)]))
})
); );
}) })
).subscribe((res) => { ).subscribe((res) => {
@ -177,7 +176,7 @@ export class AddWidgetToDashboardDialogComponent extends
private addWidgetToDashboard(dashboard: Dashboard, targetState: string, targetLayout: DashboardLayoutId) { private addWidgetToDashboard(dashboard: Dashboard, targetState: string, targetLayout: DashboardLayoutId) {
const aliasesInfo: AliasesInfo = { const aliasesInfo: AliasesInfo = {
datasourceAliases: {}, datasourceAliases: {},
targetDeviceAliases: {} targetDeviceAlias: null
}; };
aliasesInfo.datasourceAliases[0] = { aliasesInfo.datasourceAliases[0] = {
alias: this.data.entityName, alias: this.data.entityName,

2
ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html

@ -16,7 +16,7 @@
--> -->
<form class="mat-elevation-z1" <form class="mat-elevation-z1"
[formGroup]="attributeFormGroup" (ngSubmit)="update()" style="padding: 5px;"> [formGroup]="attributeFormGroup" (ngSubmit)="update()" style="padding: 5px; width: 375px;">
<fieldset [disabled]="isLoading$ | async"> <fieldset [disabled]="isLoading$ | async">
<tb-value-input <tb-value-input
formControlName="value" formControlName="value"

2
ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html

@ -21,7 +21,7 @@
</mat-spinner> </mat-spinner>
</div> </div>
<div class="mat-content" <div class="mat-content"
style="position: relative; width: 100%; height: 100%;" tb-hotkeys [hotkeys]="hotKeys" style="position: relative; width: 100%; height: 100%;" [tb-hotkeys]="hotKeys"
[cheatSheet]="dashboardCheatSheet" [cheatSheet]="dashboardCheatSheet"
[style.backgroundImage]="backgroundImage$ | async" [style.backgroundImage]="backgroundImage$ | async"
[ngStyle]="dashboardStyle"> [ngStyle]="dashboardStyle">

4
ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts

@ -39,6 +39,7 @@ import { DialogService } from '@core/services/dialog.service';
import { deepClone, isUndefined } from '@core/utils'; import { deepClone, isUndefined } from '@core/utils';
import { Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models'; import { Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models';
import { FilterDialogComponent, FilterDialogData } from '@home/components/filter/filter-dialog.component'; import { FilterDialogComponent, FilterDialogData } from '@home/components/filter/filter-dialog.component';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
export interface FiltersDialogData { export interface FiltersDialogData {
filters: Filters; filters: Filters;
@ -77,6 +78,7 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
public dialogRef: MatDialogRef<FiltersDialogComponent, Filters>, public dialogRef: MatDialogRef<FiltersDialogComponent, Filters>,
private fb: UntypedFormBuilder, private fb: UntypedFormBuilder,
private utils: UtilsService, private utils: UtilsService,
private dashboardUtils: DashboardUtilsService,
private translate: TranslateService, private translate: TranslateService,
private dialogs: DialogService, private dialogs: DialogService,
private dialog: MatDialog) { private dialog: MatDialog) {
@ -94,7 +96,7 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
} }
} else { } else {
this.data.widgets.forEach((widget) => { this.data.widgets.forEach((widget) => {
const datasources = this.utils.validateDatasources(widget.config.datasources); const datasources = this.dashboardUtils.validateAndUpdateDatasources(widget.config.datasources);
datasources.forEach((datasource) => { datasources.forEach((datasource) => {
if (datasource.type === DatasourceType.entity && datasource.filterId) { if (datasource.type === DatasourceType.entity && datasource.filterId) {
widgetsTitleList = this.filterToWidgetsMap[datasource.filterId]; widgetsTitleList = this.filterToWidgetsMap[datasource.filterId];

12
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts

@ -91,6 +91,9 @@ import {
import { import {
BarChartWithLabelsBasicConfigComponent BarChartWithLabelsBasicConfigComponent
} from '@home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component'; } from '@home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component';
import {
SingSwitchBasicConfigComponent
} from '@home/components/widget/config/basic/rpc/single-switch-basic-config.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -119,7 +122,8 @@ import {
LiquidLevelCardBasicConfigComponent, LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent, DoughnutBasicConfigComponent,
RangeChartBasicConfigComponent, RangeChartBasicConfigComponent,
BarChartWithLabelsBasicConfigComponent BarChartWithLabelsBasicConfigComponent,
SingSwitchBasicConfigComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -152,7 +156,8 @@ import {
LiquidLevelCardBasicConfigComponent, LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent, DoughnutBasicConfigComponent,
RangeChartBasicConfigComponent, RangeChartBasicConfigComponent,
BarChartWithLabelsBasicConfigComponent BarChartWithLabelsBasicConfigComponent,
SingSwitchBasicConfigComponent
] ]
}) })
export class BasicWidgetConfigModule { export class BasicWidgetConfigModule {
@ -179,5 +184,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type<IBasicWidgetCo
'tb-liquid-level-card-basic-config': LiquidLevelCardBasicConfigComponent, 'tb-liquid-level-card-basic-config': LiquidLevelCardBasicConfigComponent,
'tb-doughnut-basic-config': DoughnutBasicConfigComponent, 'tb-doughnut-basic-config': DoughnutBasicConfigComponent,
'tb-range-chart-basic-config': RangeChartBasicConfigComponent, 'tb-range-chart-basic-config': RangeChartBasicConfigComponent,
'tb-bar-chart-with-labels-basic-config': BarChartWithLabelsBasicConfigComponent 'tb-bar-chart-with-labels-basic-config': BarChartWithLabelsBasicConfigComponent,
'tb-single-switch-basic-config': SingSwitchBasicConfigComponent
}; };

214
ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html

@ -0,0 +1,214 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<ng-container [formGroup]="singleSwitchWidgetConfigForm">
<tb-target-device formControlName="targetDevice"></tb-target-device>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.single-switch.behavior</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.initial-state-hint' | translate}}" translate>widgets.rpc-state.initial-state</div>
<tb-rpc-initial-state-settings fxFlex
[stateValueType]="valueType.BOOLEAN"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
formControlName="initialState"></tb-rpc-initial-state-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.turn-on-hint' | translate}}" translate>widgets.rpc-state.turn-on</div>
<tb-rpc-update-state-settings fxFlex
panelTitle="widgets.rpc-state.turn-on"
[stateValueType]="valueType.BOOLEAN"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
formControlName="onUpdateState"></tb-rpc-update-state-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.turn-off-hint' | translate}}" translate>widgets.rpc-state.turn-off</div>
<tb-rpc-update-state-settings fxFlex
panelTitle="widgets.rpc-state.turn-off"
[stateValueType]="valueType.BOOLEAN"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
formControlName="offUpdateState"></tb-rpc-update-state-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="2:1"
[cols]="{columns: 3,
breakpoints: {
'lt-sm': 1,
'lt-md': 2
}}"
label="{{ 'widgets.single-switch.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of singleSwitchLayouts"
[value]="layout"
[image]="singleSwitchLayoutImageMap.get(layout)">
{{ singleSwitchLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.single-switch.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.single-switch.label' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="labelFont"
[previewText]="singleSwitchWidgetConfigForm.get('label').value">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="labelColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.single-switch.icon' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
[color]="singleSwitchWidgetConfigForm.get('iconColor').value?.color"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
colorClearButton
formControlName="iconColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.single-switch.switch-color' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutAlign.lt-sm="space-between center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.on</div>
<tb-color-input asBoxInput
formControlName="switchColorOn">
</tb-color-input>
</div>
<mat-divider vertical fxHide.lt-sm></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.off</div>
<tb-color-input asBoxInput
formControlName="switchColorOff">
</tb-color-input>
</div>
<mat-divider vertical fxHide.lt-sm></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.disabled</div>
<tb-color-input asBoxInput
formControlName="switchColorDisabled">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.single-switch.tumbler-color' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutAlign.lt-sm="space-between center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.on</div>
<tb-color-input asBoxInput
formControlName="tumblerColorOn">
</tb-color-input>
</div>
<mat-divider vertical fxHide.lt-sm></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.off</div>
<tb-color-input asBoxInput
formControlName="tumblerColorOff">
</tb-color-input>
</div>
<mat-divider vertical fxHide.lt-sm></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.disabled</div>
<tb-color-input asBoxInput
formControlName="tumblerColorDisabled">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showOnLabel">
{{ 'widgets.single-switch.on-label' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="onLabel" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="onLabelFont"
[previewText]="singleSwitchWidgetConfigForm.get('onLabel').value">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="onLabelColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showOffLabel">
{{ 'widgets.single-switch.off-label' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="offLabel" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="offLabelFont"
[previewText]="singleSwitchWidgetConfigForm.get('offLabel').value">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="offLabelColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-border-radius' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-widget-actions-panel
formControlName="actions">
</tb-widget-actions-panel>
</ng-container>

231
ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts

@ -0,0 +1,231 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { TargetDevice, WidgetConfig, } from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { isUndefined } from '@core/utils';
import {
singleSwitchDefaultSettings,
singleSwitchLayoutImages,
singleSwitchLayouts,
singleSwitchLayoutTranslations,
SingleSwitchWidgetSettings
} from '@home/components/widget/lib/rpc/single-switch-widget.models';
import { ValueType } from '@shared/models/constants';
@Component({
selector: 'tb-single-switch-basic-config',
templateUrl: './single-switch-basic-config.component.html',
styleUrls: ['../basic-config.scss']
})
export class SingSwitchBasicConfigComponent extends BasicWidgetConfigComponent {
get targetDevice(): TargetDevice {
return this.singleSwitchWidgetConfigForm.get('targetDevice').value;
}
singleSwitchLayouts = singleSwitchLayouts;
singleSwitchLayoutTranslationMap = singleSwitchLayoutTranslations;
singleSwitchLayoutImageMap = singleSwitchLayoutImages;
valueType = ValueType;
singleSwitchWidgetConfigForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.singleSwitchWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: SingleSwitchWidgetSettings = {...singleSwitchDefaultSettings, ...(configData.config.settings || {})};
this.singleSwitchWidgetConfigForm = this.fb.group({
targetDevice: [configData.config.targetDevice, []],
initialState: [settings.initialState, []],
onUpdateState: [settings.onUpdateState, []],
offUpdateState: [settings.offUpdateState, []],
layout: [settings.layout, []],
autoScale: [settings.autoScale, []],
showLabel: [settings.showLabel, []],
label: [settings.label, []],
labelFont: [settings.labelFont, []],
labelColor: [settings.labelColor, []],
showIcon: [settings.showIcon, []],
iconSize: [settings.iconSize, [Validators.min(0)]],
iconSizeUnit: [settings.iconSizeUnit, []],
icon: [settings.icon, []],
iconColor: [settings.iconColor, []],
switchColorOn: [settings.switchColorOn, []],
switchColorOff: [settings.switchColorOff, []],
switchColorDisabled: [settings.switchColorDisabled, []],
tumblerColorOn: [settings.tumblerColorOn, []],
tumblerColorOff: [settings.tumblerColorOff, []],
tumblerColorDisabled: [settings.tumblerColorDisabled, []],
showOnLabel: [settings.showOnLabel, []],
onLabel: [settings.onLabel, []],
onLabelFont: [settings.onLabelFont, []],
onLabelColor: [settings.onLabelColor, []],
showOffLabel: [settings.showOffLabel, []],
offLabel: [settings.offLabel, []],
offLabelFont: [settings.offLabelFont, []],
offLabelColor: [settings.offLabelColor, []],
background: [settings.background, []],
cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []],
actions: [configData.config.actions || {}, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.targetDevice = config.targetDevice;
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
this.widgetConfig.config.settings.initialState = config.initialState;
this.widgetConfig.config.settings.onUpdateState = config.onUpdateState;
this.widgetConfig.config.settings.offUpdateState = config.offUpdateState;
this.widgetConfig.config.settings.layout = config.layout;
this.widgetConfig.config.settings.autoScale = config.autoScale;
this.widgetConfig.config.settings.showLabel = config.showLabel;
this.widgetConfig.config.settings.label = config.label;
this.widgetConfig.config.settings.labelFont = config.labelFont;
this.widgetConfig.config.settings.labelColor = config.labelColor;
this.widgetConfig.config.settings.showIcon = config.showIcon;
this.widgetConfig.config.settings.iconSize = config.iconSize;
this.widgetConfig.config.settings.iconSizeUnit = config.iconSizeUnit;
this.widgetConfig.config.settings.icon = config.icon;
this.widgetConfig.config.settings.iconColor = config.iconColor;
this.widgetConfig.config.settings.switchColorOn = config.switchColorOn;
this.widgetConfig.config.settings.switchColorOff = config.switchColorOff;
this.widgetConfig.config.settings.switchColorDisabled = config.switchColorDisabled;
this.widgetConfig.config.settings.tumblerColorOn = config.tumblerColorOn;
this.widgetConfig.config.settings.tumblerColorOff = config.tumblerColorOff;
this.widgetConfig.config.settings.tumblerColorDisabled = config.tumblerColorDisabled;
this.widgetConfig.config.settings.showOnLabel = config.showOnLabel;
this.widgetConfig.config.settings.onLabel = config.onLabel;
this.widgetConfig.config.settings.onLabelFont = config.onLabelFont;
this.widgetConfig.config.settings.onLabelColor = config.onLabelColor;
this.widgetConfig.config.settings.showOffLabel = config.showOffLabel;
this.widgetConfig.config.settings.offLabel = config.offLabel;
this.widgetConfig.config.settings.offLabelFont = config.offLabelFont;
this.widgetConfig.config.settings.offLabelColor = config.offLabelColor;
this.widgetConfig.config.settings.background = config.background;
this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.actions = config.actions;
return this.widgetConfig;
}
protected validatorTriggers(): string[] {
return ['showLabel', 'showIcon', 'showOnLabel', 'showOffLabel'];
}
protected updateValidators(emitEvent: boolean, trigger?: string) {
const showLabel: boolean = this.singleSwitchWidgetConfigForm.get('showLabel').value;
const showIcon: boolean = this.singleSwitchWidgetConfigForm.get('showIcon').value;
const showOnLabel: boolean = this.singleSwitchWidgetConfigForm.get('showOnLabel').value;
const showOffLabel: boolean = this.singleSwitchWidgetConfigForm.get('showOffLabel').value;
if (showLabel) {
this.singleSwitchWidgetConfigForm.get('label').enable();
this.singleSwitchWidgetConfigForm.get('labelFont').enable();
this.singleSwitchWidgetConfigForm.get('labelColor').enable();
} else {
this.singleSwitchWidgetConfigForm.get('label').disable();
this.singleSwitchWidgetConfigForm.get('labelFont').disable();
this.singleSwitchWidgetConfigForm.get('labelColor').disable();
}
if (showIcon) {
this.singleSwitchWidgetConfigForm.get('iconSize').enable();
this.singleSwitchWidgetConfigForm.get('iconSizeUnit').enable();
this.singleSwitchWidgetConfigForm.get('icon').enable();
this.singleSwitchWidgetConfigForm.get('iconColor').enable();
} else {
this.singleSwitchWidgetConfigForm.get('iconSize').disable();
this.singleSwitchWidgetConfigForm.get('iconSizeUnit').disable();
this.singleSwitchWidgetConfigForm.get('icon').disable();
this.singleSwitchWidgetConfigForm.get('iconColor').disable();
}
if (showOnLabel) {
this.singleSwitchWidgetConfigForm.get('onLabel').enable();
this.singleSwitchWidgetConfigForm.get('onLabelFont').enable();
this.singleSwitchWidgetConfigForm.get('onLabelColor').enable();
} else {
this.singleSwitchWidgetConfigForm.get('onLabel').disable();
this.singleSwitchWidgetConfigForm.get('onLabelFont').disable();
this.singleSwitchWidgetConfigForm.get('onLabelColor').disable();
}
if (showOffLabel) {
this.singleSwitchWidgetConfigForm.get('offLabel').enable();
this.singleSwitchWidgetConfigForm.get('offLabelFont').enable();
this.singleSwitchWidgetConfigForm.get('offLabelColor').enable();
} else {
this.singleSwitchWidgetConfigForm.get('offLabel').disable();
this.singleSwitchWidgetConfigForm.get('offLabelFont').disable();
this.singleSwitchWidgetConfigForm.get('offLabelColor').disable();
}
}
private getCardButtons(config: WidgetConfig): string[] {
const buttons: string[] = [];
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) {
buttons.push('fullscreen');
}
return buttons;
}
private setCardButtons(buttons: string[], config: WidgetConfig) {
config.enableFullscreen = buttons.includes('fullscreen');
}
}

38
ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html

@ -0,0 +1,38 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div [formGroup]="targetDeviceFormGroup" class="tb-form-panel">
<div fxLayout="row" fxLayoutAlign="space-between center">
<div class="tb-form-panel-title" translate>widget-config.target-device</div>
<tb-toggle-select formControlName="type">
<tb-toggle-option [value]="targetDeviceType.device">{{ 'device.device' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="targetDeviceType.entity">{{ 'entity.entity-alias' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-entity-autocomplete *ngIf="targetDeviceFormGroup.get('type').value === targetDeviceType.device"
[required]="!widgetEditMode"
[entityType]="entityType.DEVICE"
formControlName="deviceId">
</tb-entity-autocomplete>
<tb-entity-alias-select
*ngIf="targetDeviceFormGroup.get('type').value === targetDeviceType.entity"
[tbRequired]="!widgetEditMode"
[aliasController]="aliasController"
[callbacks]="entityAliasSelectCallbacks"
formControlName="entityAliasId">
</tb-entity-alias-select>
</div>

166
ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts

@ -0,0 +1,166 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator,
Validators
} from '@angular/forms';
import { Component, forwardRef, Input, OnInit } from '@angular/core';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { TargetDevice, TargetDeviceType } from '@shared/models/widget.models';
import { EntityType } from '@shared/models/entity-type.models';
import { IAliasController } from '@core/api/widget-api.models';
import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models';
@Component({
selector: 'tb-target-device',
templateUrl: './target-device.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TargetDeviceComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => TargetDeviceComponent),
multi: true,
}
]
})
export class TargetDeviceComponent implements ControlValueAccessor, OnInit, Validator {
public get aliasController(): IAliasController {
return this.widgetConfigComponent.aliasController;
}
public get entityAliasSelectCallbacks(): EntityAliasSelectCallbacks {
return this.widgetConfigComponent.widgetConfigCallbacks;
}
targetDeviceType = TargetDeviceType;
entityType = EntityType;
@Input()
disabled: boolean;
widgetEditMode = this.utils.widgetEditMode;
targetDeviceFormGroup: UntypedFormGroup;
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private utils: UtilsService,
public translate: TranslateService,
private widgetConfigComponent: WidgetConfigComponent) {
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
if (!this.targetDeviceFormGroup.valid) {
setTimeout(() => {
this.targetDeviceUpdated(this.targetDeviceFormGroup.value);
}, 0);
}
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.targetDeviceFormGroup.disable({emitEvent: false});
} else {
this.targetDeviceFormGroup.enable({emitEvent: false});
this.updateValidators();
}
}
ngOnInit() {
this.targetDeviceFormGroup = this.fb.group({
type: [null, !this.widgetEditMode ? [Validators.required] : []],
deviceId: [null, !this.widgetEditMode ? [Validators.required] : []],
entityAliasId: [null, !this.widgetEditMode ? [Validators.required] : []]
});
this.targetDeviceFormGroup.get('type').valueChanges.subscribe(() => {
this.updateValidators();
});
this.targetDeviceFormGroup.valueChanges.subscribe(
() => {
this.targetDeviceUpdated(this.targetDeviceFormGroup.value);
}
);
}
writeValue(targetDevice?: TargetDevice): void {
this.targetDeviceFormGroup.patchValue({
type: targetDevice?.type || TargetDeviceType.device,
deviceId: targetDevice?.deviceId,
entityAliasId: targetDevice?.entityAliasId
}, {emitEvent: false});
this.updateValidators();
if (targetDevice?.type === TargetDeviceType.entity && targetDevice?.entityAliasId) {
const entityAliases = this.aliasController.getEntityAliases();
if (!entityAliases[targetDevice.entityAliasId]) {
this.targetDeviceFormGroup.get('entityAliasId').patchValue(null, {emitEvent: false});
setTimeout(() => {
this.targetDeviceUpdated(this.targetDeviceFormGroup.value);
}, 0);
}
}
}
validate(c: UntypedFormControl) {
return (this.targetDeviceFormGroup.valid) ? null : {
targetDevice: {
valid: false,
},
};
}
private targetDeviceUpdated(targetDevice: TargetDevice) {
this.propagateChange(targetDevice);
}
private updateValidators() {
const type: TargetDeviceType = this.targetDeviceFormGroup.get('type').value;
if (!this.widgetEditMode && type) {
if (type === TargetDeviceType.device) {
this.targetDeviceFormGroup.get('deviceId').enable({emitEvent: false});
this.targetDeviceFormGroup.get('entityAliasId').disable({emitEvent: false});
} else {
this.targetDeviceFormGroup.get('deviceId').disable({emitEvent: false});
this.targetDeviceFormGroup.get('entityAliasId').enable({emitEvent: false});
}
} else {
this.targetDeviceFormGroup.get('deviceId').disable({emitEvent: false});
this.targetDeviceFormGroup.get('entityAliasId').disable({emitEvent: false});
}
}
}

3
ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts

@ -32,6 +32,7 @@ import { TimewindowConfigPanelComponent } from '@home/components/widget/config/t
import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module'; import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module';
import { TimewindowStyleComponent } from '@home/components/widget/config/timewindow-style.component'; import { TimewindowStyleComponent } from '@home/components/widget/config/timewindow-style.component';
import { TimewindowStylePanelComponent } from '@home/components/widget/config/timewindow-style-panel.component'; import { TimewindowStylePanelComponent } from '@home/components/widget/config/timewindow-style-panel.component';
import { TargetDeviceComponent } from '@home/components/widget/config/target-device.component';
@NgModule({ @NgModule({
declarations: declarations:
@ -43,6 +44,7 @@ import { TimewindowStylePanelComponent } from '@home/components/widget/config/ti
DataKeyConfigComponent, DataKeyConfigComponent,
DatasourceComponent, DatasourceComponent,
DatasourcesComponent, DatasourcesComponent,
TargetDeviceComponent,
EntityAliasSelectComponent, EntityAliasSelectComponent,
FilterSelectComponent, FilterSelectComponent,
TimewindowStyleComponent, TimewindowStyleComponent,
@ -64,6 +66,7 @@ import { TimewindowStylePanelComponent } from '@home/components/widget/config/ti
DataKeyConfigComponent, DataKeyConfigComponent,
DatasourceComponent, DatasourceComponent,
DatasourcesComponent, DatasourcesComponent,
TargetDeviceComponent,
EntityAliasSelectComponent, EntityAliasSelectComponent,
FilterSelectComponent, FilterSelectComponent,
TimewindowStyleComponent, TimewindowStyleComponent,

5
ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts

@ -27,6 +27,7 @@ import { DataKey, DatasourceType, KeyInfo, WidgetConfigMode } from '@shared/mode
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { isDefinedAndNotNull } from '@core/utils'; import { isDefinedAndNotNull } from '@core/utils';
import { IAliasController } from '@core/api/widget-api.models';
export type WidgetConfigCallbacks = DatasourceCallbacks & WidgetActionCallbacks; export type WidgetConfigCallbacks = DatasourceCallbacks & WidgetActionCallbacks;
@ -58,6 +59,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement
return this.widgetConfigValue; return this.widgetConfigValue;
} }
get aliasController(): IAliasController {
return this.widgetConfigComponent.aliasController;
}
widgetConfigChangedEmitter = new EventEmitter<WidgetConfigComponentData>(); widgetConfigChangedEmitter = new EventEmitter<WidgetConfigComponentData>();
widgetConfigChanged = this.widgetConfigChangedEmitter.asObservable(); widgetConfigChanged = this.widgetConfigChangedEmitter.asObservable();

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

@ -57,7 +57,7 @@ export class DynamicWidgetComponent extends PageComponent implements IDynamicWid
executingRpcRequest: boolean; executingRpcRequest: boolean;
rpcEnabled: boolean; rpcEnabled: boolean;
rpcErrorText: string; rpcErrorText: string;
rpcRejection: HttpErrorResponse; rpcRejection: HttpErrorResponse | Error;
[key: string]: any; [key: string]: any;

626
ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widget.models.ts

@ -0,0 +1,626 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
AttributeData,
AttributeScope,
LatestTelemetry,
telemetryTypeTranslationsShort
} from '@shared/models/telemetry/telemetry.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { BehaviorSubject, Observable, of, throwError } from 'rxjs';
import { catchError, delay, map, share } from 'rxjs/operators';
import { UtilsService } from '@core/services/utils.service';
import { AfterViewInit, ChangeDetectorRef, Directive, Input, OnInit, TemplateRef } from '@angular/core';
import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
import {
RpcActionSettings,
RpcGetAttributeSettings,
RpcSetAttributeSettings,
RpcSettings,
RpcStateToParamsSettings,
RpcStateToParamsType,
RpcStateWidgetSettings,
RpcTelemetrySettings,
RpcUpdateStateAction
} from '@shared/models/rpc-widget-settings.models';
import {
RpcDataToStateSettings,
RpcDataToStateType,
RpcInitialStateAction,
RpcInitialStateSettings,
RpcStateBehaviourSettings,
RpcUpdateStateSettings
} from '@app/shared/models/rpc-widget-settings.models';
import { ValueType } from '@shared/models/constants';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
@Directive()
// eslint-disable-next-line @angular-eslint/directive-class-suffix
export abstract class BasicRpcStateWidgetComponent<V, S extends RpcStateWidgetSettings<V>> implements OnInit, AfterViewInit {
@Input()
ctx: WidgetContext;
@Input()
widgetTitlePanel: TemplateRef<any>;
settings: S;
behaviorApi: RpcStateBehaviorApi<V>;
loading$: Observable<boolean>;
backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {};
value: V;
error = '';
protected constructor(protected imagePipe: ImagePipe,
protected sanitizer: DomSanitizer,
protected cd: ChangeDetectorRef) {
}
ngOnInit(): void {
this.ctx.$scope.rpcWidget = this;
this.settings = {...this.defaultSettings(), ...this.ctx.settings};
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
const behaviourSettings: RpcStateBehaviourSettings<V> = {
initialState: this.initialState(),
updateStateByValue: val => this.getUpdateStateSettingsForValue(val)
};
const callbacks: RpcStateCallbacks<V> = {
onStateValue: val => this.setValue(val),
onError: err => this.onError(err),
validateStateValue: val => this.validateValue(val)
};
this.behaviorApi = new RpcStateBehaviorApi<V>(this.defaultValue(), this.ctx,
behaviourSettings, callbacks, this.stateValueType());
this.loading$ = this.behaviorApi.loading$.pipe(share());
}
ngAfterViewInit(): void {
this.behaviorApi.initState();
}
public onInit() {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges();
}
public clearError() {
this.error = '';
}
public updateValue() {
this.behaviorApi.updateState(this.value);
}
private onError(error: string) {
this.error = error;
this.ctx.detectChanges();
}
protected abstract defaultSettings(): S;
protected abstract initialState(): RpcInitialStateSettings<V>;
protected abstract getUpdateStateSettingsForValue(value: V): RpcUpdateStateSettings;
protected stateValueType(): ValueType {
return ValueType.BOOLEAN;
}
protected defaultValue(): V {
return null;
}
protected setValue(value: V) {
this.value = value;
}
protected validateValue(value: any): V {
return value;
}
}
export abstract class RpcHasLoading {
public get loading$() {
return this.loadingSubject.asObservable();
}
protected loadingSubject = new BehaviorSubject(false);
}
export interface RpcStateCallbacks<V> {
onStateValue: (value: V) => void;
validateStateValue: (value: any) => V;
onError: (error: string) => void;
}
export class RpcStateBehaviorApi<V> extends RpcHasLoading {
private readonly initialStateGetter: RpcInitialStateGetter<V>;
private readonly stateUpdatersMap: Map<RpcUpdateStateSettings, RpcStateUpdater<V>>;
constructor(private state: V,
private ctx: WidgetContext,
private settings: RpcStateBehaviourSettings<V>,
private callbacks: RpcStateCallbacks<V>,
stateValueType: ValueType) {
super();
this.initialStateGetter = RpcInitialStateGetter.fromSettings(ctx, settings.initialState, stateValueType, callbacks);
this.stateUpdatersMap = new Map<RpcUpdateStateSettings, RpcStateUpdater<V>>();
}
initState() {
if (this.ctx.defaultSubscription.targetEntityId || this.ctx.defaultSubscription.rpcEnabled) {
this.loadingSubject.next(true);
this.initialStateGetter.initState().subscribe(
{
next: (value) => {
this.state = value;
this.loadingSubject.next(false);
this.callbacks.onStateValue(value);
this.ctx.detectChanges();
},
error: (err: any) => {
this.loadingSubject.next(false);
const message = parseError(this.ctx, err);
this.callbacks.onError(message);
}
}
);
} else {
this.callbacks.onError(this.ctx.translate.instant('widgets.rpc-state.error.target-entity-is-not-set'));
}
}
updateState(value: V) {
this.callbacks.onError(null);
let updater: RpcStateUpdater<V>;
const updateStateSettings = this.settings.updateStateByValue(value);
if (updateStateSettings) {
updater = this.stateUpdatersMap.get(updateStateSettings);
if (!updater) {
updater = RpcStateUpdater.fromSettings(this.ctx, updateStateSettings);
this.stateUpdatersMap.set(updateStateSettings, updater);
}
}
if (updater) {
this.loadingSubject.next(true);
updater.updateState(value).subscribe(
{
next: () => {
this.state = value;
this.loadingSubject.next(false);
this.ctx.detectChanges();
},
error: (err: any) => {
this.loadingSubject.next(false);
const message = parseError(this.ctx, err);
this.callbacks.onStateValue(this.state);
this.callbacks.onError(message);
this.ctx.detectChanges();
}
});
}
}
}
type RpcDataToStateFunction<V> = (data: any) => V;
export class RpcDataToStateConverter<V> {
private readonly dataToStateFunction: RpcDataToStateFunction<V>;
private readonly compareToValue: any;
constructor(private settings: RpcDataToStateSettings,
private stateValueType: ValueType,
private callbacks: RpcStateCallbacks<V>) {
this.compareToValue = settings.compareToValue;
switch (settings.type) {
case RpcDataToStateType.FUNCTION:
try {
this.dataToStateFunction = new Function('data', settings.dataToStateFunction) as RpcDataToStateFunction<V>;
} catch (e) {
this.dataToStateFunction = (data) => data;
}
break;
case RpcDataToStateType.NONE:
break;
}
}
dataToStateValue(data: any): V {
let result: V;
switch (this.settings.type) {
case RpcDataToStateType.FUNCTION:
result = data;
try {
result = this.dataToStateFunction(!!data ? JSON.parse(data) : data);
} catch (e) {}
break;
case RpcDataToStateType.NONE:
result = data;
break;
}
if (this.stateValueType === ValueType.BOOLEAN) {
result = (result === this.compareToValue) as any;
}
result = this.callbacks.validateStateValue(result);
return result;
}
}
export abstract class RpcAction {
protected constructor(protected ctx: WidgetContext,
protected settings: RpcActionSettings) {}
handleError(err: any): Error {
const reason = parseError(this.ctx, err);
let errorMessage = this.ctx.translate.instant('widgets.rpc-state.error.failed-to-perform-action',
{actionLabel: this.settings.actionLabel});
if (reason) {
errorMessage += '<br>' + reason;
}
return new Error(errorMessage);
}
}
export abstract class RpcInitialStateGetter<V> extends RpcAction {
static fromSettings<V>(ctx: WidgetContext,
settings: RpcInitialStateSettings<V>,
stateValueType: ValueType,
callbacks: RpcStateCallbacks<V>): RpcInitialStateGetter<V> {
switch (settings.action) {
case RpcInitialStateAction.DO_NOTHING:
return new RpcDefaultStateGetter<V>(ctx, settings, stateValueType, callbacks);
case RpcInitialStateAction.EXECUTE_RPC:
return new ExecuteRpcStateGetter<V>(ctx, settings, stateValueType, callbacks);
case RpcInitialStateAction.GET_ATTRIBUTE:
return new RpcAttributeStateGetter<V>(ctx, settings, stateValueType, callbacks);
case RpcInitialStateAction.GET_TIME_SERIES:
return new RpcTimeSeriesStateGetter<V>(ctx, settings, stateValueType, callbacks);
}
}
private readonly isSimulated: boolean;
private readonly dataConverter: RpcDataToStateConverter<V>;
protected constructor(protected ctx: WidgetContext,
protected settings: RpcInitialStateSettings<V>,
protected stateValueType: ValueType,
protected callbacks: RpcStateCallbacks<V>) {
super(ctx, settings);
this.isSimulated = this.ctx.$injector.get(UtilsService).widgetEditMode;
if (this.settings.action !== RpcInitialStateAction.DO_NOTHING) {
this.dataConverter = new RpcDataToStateConverter<V>(settings.dataToState, stateValueType, this.callbacks);
}
}
initState(): Observable<V> {
const stateObservable: Observable<V> = this.isSimulated ? of(null).pipe(delay(500)) : this.doGetState();
return stateObservable.pipe(
map((data) => {
if (this.dataConverter) {
return this.dataConverter.dataToStateValue(data);
} else {
return data;
}
}),
catchError(err => {
throw this.handleError(err);
})
);
}
protected abstract doGetState(): Observable<any>;
}
type RpcStateToParamsFunction<V> = (state: V) => any;
export class RpcStateToParamsConverter<V> {
private readonly constantValue: any;
private readonly stateToParamsFunction: RpcStateToParamsFunction<V>;
constructor(protected settings: RpcStateToParamsSettings) {
switch (settings.type) {
case RpcStateToParamsType.CONSTANT:
this.constantValue = this.settings.constantValue;
break;
case RpcStateToParamsType.FUNCTION:
try {
this.stateToParamsFunction = new Function('value', settings.stateToParamsFunction) as RpcStateToParamsFunction<V>;
} catch (e) {
this.stateToParamsFunction = (data) => data;
}
break;
case RpcStateToParamsType.NONE:
break;
}
}
stateToParams(state: V): any {
switch (this.settings.type) {
case RpcStateToParamsType.CONSTANT:
return this.constantValue;
case RpcStateToParamsType.FUNCTION:
let result = state;
try {
result = this.stateToParamsFunction(state);
} catch (e) {}
return result;
case RpcStateToParamsType.NONE:
return null;
}
}
}
export abstract class RpcStateUpdater<V> extends RpcAction {
static fromSettings<V>(ctx: WidgetContext,
settings: RpcUpdateStateSettings): RpcStateUpdater<V> {
switch (settings.action) {
case RpcUpdateStateAction.EXECUTE_RPC:
return new ExecuteRpcStateUpdater<V>(ctx, settings);
case RpcUpdateStateAction.SET_ATTRIBUTE:
return new RpcAttributeStateUpdater<V>(ctx, settings);
case RpcUpdateStateAction.ADD_TIME_SERIES:
return new RpcTimeSeriesStateUpdater<V>(ctx, settings);
}
}
private readonly isSimulated: boolean;
private readonly paramsConverter: RpcStateToParamsConverter<V>;
protected constructor(protected ctx: WidgetContext,
protected settings: RpcUpdateStateSettings) {
super(ctx, settings);
this.isSimulated = this.ctx.$injector.get(UtilsService).widgetEditMode;
this.paramsConverter = new RpcStateToParamsConverter<V>(settings.stateToParams);
}
updateState(state: V): Observable<any> {
if (this.isSimulated) {
return of(null).pipe(delay(500));
} else {
return this.doUpdateState(this.paramsConverter.stateToParams(state)).pipe(
catchError(err => {
throw this.handleError(err);
})
);
}
}
protected abstract doUpdateState(params: any): Observable<any>;
}
export class RpcDefaultStateGetter<V> extends RpcInitialStateGetter<V> {
private readonly defaultValue: V;
constructor(protected ctx: WidgetContext,
protected settings: RpcInitialStateSettings<V>,
protected stateValueType: ValueType,
protected callbacks: RpcStateCallbacks<V>) {
super(ctx, settings, stateValueType, callbacks);
this.defaultValue = settings.defaultValue;
}
protected doGetState(): Observable<V> {
return of(this.defaultValue);
}
}
export class ExecuteRpcStateGetter<V> extends RpcInitialStateGetter<V> {
private readonly executeRpcSettings: RpcSettings;
constructor(protected ctx: WidgetContext,
protected settings: RpcInitialStateSettings<V>,
protected stateValueType: ValueType,
protected callbacks: RpcStateCallbacks<V>) {
super(ctx, settings, stateValueType, callbacks);
this.executeRpcSettings = settings.executeRpc;
}
protected doGetState(): Observable<V> {
return this.ctx.controlApi.sendTwoWayCommand(this.executeRpcSettings.method, null,
this.executeRpcSettings.requestTimeout,
this.executeRpcSettings.requestPersistent,
this.executeRpcSettings.persistentPollingInterval).pipe(
catchError((err) => {
throw handleRpcError(this.ctx, err);
})
);
}
}
export class RpcAttributeStateGetter<V> extends RpcInitialStateGetter<V> {
private readonly getAttributeSettings: RpcGetAttributeSettings;
constructor(protected ctx: WidgetContext,
protected settings: RpcInitialStateSettings<V>,
protected stateValueType: ValueType,
protected callbacks: RpcStateCallbacks<V>) {
super(ctx, settings, stateValueType, callbacks);
this.getAttributeSettings = settings.getAttribute;
}
protected doGetState(): Observable<V> {
if (this.ctx.defaultSubscription.targetEntityId) {
const err = validateAttributeScope(this.ctx, this.getAttributeSettings.scope);
if (err) {
return throwError(() => err);
}
return this.ctx.attributeService.getEntityAttributes(this.ctx.defaultSubscription.targetEntityId,
this.getAttributeSettings.scope, [this.getAttributeSettings.key], {ignoreLoading: true, ignoreErrors: true})
.pipe(
map((data) => data.find(attr => attr.key === this.getAttributeSettings.key)?.value)
);
} else {
return of(null);
}
}
}
export class RpcTimeSeriesStateGetter<V> extends RpcInitialStateGetter<V> {
private readonly getTimeSeriesSettings: RpcTelemetrySettings;
constructor(protected ctx: WidgetContext,
protected settings: RpcInitialStateSettings<V>,
protected stateValueType: ValueType,
protected callbacks: RpcStateCallbacks<V>) {
super(ctx, settings, stateValueType, callbacks);
this.getTimeSeriesSettings = settings.getTimeSeries;
}
protected doGetState(): Observable<V> {
if (this.ctx.defaultSubscription.targetEntityId) {
return this.ctx.attributeService.getEntityTimeseriesLatest(this.ctx.defaultSubscription.targetEntityId,
[this.getTimeSeriesSettings.key], true, {ignoreLoading: true, ignoreErrors: true})
.pipe(
map((data) => {
let value: any = null;
if (data[this.getTimeSeriesSettings.key]) {
const dataSet = data[this.getTimeSeriesSettings.key];
if (dataSet.length) {
value = dataSet[0].value;
}
}
return value;
})
);
} else {
return of(null);
}
}
}
export class ExecuteRpcStateUpdater<V> extends RpcStateUpdater<V> {
private readonly executeRpcSettings: RpcSettings;
constructor(protected ctx: WidgetContext,
protected settings: RpcUpdateStateSettings) {
super(ctx, settings);
this.executeRpcSettings = settings.executeRpc;
}
protected doUpdateState(params: any): Observable<any> {
return this.ctx.controlApi.sendOneWayCommand(this.executeRpcSettings.method, params,
this.executeRpcSettings.requestTimeout,
this.executeRpcSettings.requestPersistent,
this.executeRpcSettings.persistentPollingInterval).pipe(
catchError((err) => {
throw handleRpcError(this.ctx, err);
})
);
}
}
export class RpcAttributeStateUpdater<V> extends RpcStateUpdater<V> {
private readonly setAttributeSettings: RpcSetAttributeSettings;
constructor(protected ctx: WidgetContext,
protected settings: RpcUpdateStateSettings) {
super(ctx, settings);
this.setAttributeSettings = settings.setAttribute;
}
protected doUpdateState(params: any): Observable<any> {
if (this.ctx.defaultSubscription.targetEntityId) {
const err = validateAttributeScope(this.ctx, this.setAttributeSettings.scope);
if (err) {
return throwError(() => err);
}
const attributes: Array<AttributeData> = [{key: this.setAttributeSettings.key, value: params}];
return this.ctx.attributeService.saveEntityAttributes(this.ctx.defaultSubscription.targetEntityId,
this.setAttributeSettings.scope, attributes, {ignoreLoading: true, ignoreErrors: true});
} else {
return of(null);
}
}
}
export class RpcTimeSeriesStateUpdater<V> extends RpcStateUpdater<V> {
private readonly putTimeSeriesSettings: RpcTelemetrySettings;
constructor(protected ctx: WidgetContext,
protected settings: RpcUpdateStateSettings) {
super(ctx, settings);
this.putTimeSeriesSettings = settings.putTimeSeries;
}
protected doUpdateState(params: any): Observable<any> {
if (this.ctx.defaultSubscription.targetEntityId) {
const timeSeries: Array<AttributeData> = [{key: this.putTimeSeriesSettings.key, value: params}];
return this.ctx.attributeService.saveEntityTimeseries(this.ctx.defaultSubscription.targetEntityId,
LatestTelemetry.LATEST_TELEMETRY, timeSeries, {ignoreLoading: true, ignoreErrors: true});
} else {
return of(null);
}
}
}
const parseError = (ctx: WidgetContext, err: any): string =>
ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error';
const handleRpcError = (ctx: WidgetContext, err: any): Error => {
let reason: string;
if (ctx.defaultSubscription.rpcErrorText) {
reason = ctx.defaultSubscription.rpcErrorText;
} else {
reason = parseError(ctx, err);
}
return new Error(reason);
};
const validateAttributeScope = (ctx: WidgetContext, scope?: AttributeScope): Error | null => {
if (ctx.defaultSubscription.targetEntityId.entityType !== EntityType.DEVICE && scope && scope !== AttributeScope.SERVER_SCOPE) {
const scopeStr = ctx.translate.instant(telemetryTypeTranslationsShort.get(scope));
const entityType =
ctx.translate.instant(entityTypeTranslations.get(ctx.defaultSubscription.targetEntityId.entityType).type);
const errorMessage =
ctx.translate.instant('widgets.rpc-state.error.invalid-attribute-scope', {scope: scopeStr, entityType});
return new Error(errorMessage);
} else {
return null;
}
};

42
ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html

@ -0,0 +1,42 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div #singleSwitchPanel class="tb-single-switch-panel" [style.pointer-events]="ctx.isEdit ? 'none' : 'all'" [style]="backgroundStyle$ | async">
<div class="tb-single-switch-overlay" [style]="overlayStyle"></div>
<div class="tb-single-switch-title-panel">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
</div>
<div #singleSwitchContent class="tb-single-switch-content" [class]="this.layout" [class.no-label]="!showIcon && !showLabel" [class.auto-scale]="autoScale">
<div *ngIf="showIcon || showLabel" #singleSwitchLabelRow class="tb-single-switch-label-row">
<tb-icon *ngIf="showIcon" [style]="iconStyle">{{ icon }}</tb-icon>
<div *ngIf="showLabel" class="tb-single-switch-label" [style]="labelStyle">{{ label$ | async }}</div>
</div>
<div #singleSwitchToggleRow class="tb-single-switch-slide-toggle-row">
<div *ngIf="showOffLabel" [style]="offLabelStyle">{{ offLabel }}</div>
<mat-slide-toggle class="tb-single-switch-toggle" [disabled]="loading$ | async" [(ngModel)]="value" (change)="updateValue()">
</mat-slide-toggle>
<div *ngIf="showOnLabel" [style]="onLabelStyle">{{ onLabel }}</div>
</div>
</div>
<mat-progress-bar class="tb-single-switch-progress" style="height: 2px;" color="accent" mode="indeterminate" *ngIf="loading$ | async"></mat-progress-bar>
<div *ngIf="error" class="tb-single-switch-error-container">
<div class="tb-single-switch-error-panel">
<div class="tb-single-switch-error-text" [innerHTML]="error | safe: 'html'"></div>
<button class="tb-single-switch-error-clear tb-mat-20" mat-icon-button (click)="clearError()"><mat-icon>close</mat-icon></button>
</div>
</div>
</div>

204
ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.scss

@ -0,0 +1,204 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
$tumblerColorOn: var(--tb-single-switch-tumbler-color-on, #fff);
$tumblerColorOff: var(--tb-single-switch-tumbler-color-off, #fff);
$tumblerColorDisabled: var(--tb-single-switch-tumbler-color-disabled, #fff);
$switchColorOn: var(--tb-single-switch-color-on, #5469FF);
$switchColorOff: var(--tb-single-switch-color-off, rgba(84, 105, 255, 0.30));
$switchColorDisabled: var(--tb-single-switch-color-disabled, #D5D7E5);
.tb-single-switch-panel {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 18px 24px;
> div:not(.tb-single-switch-overlay), > tb-icon {
z-index: 1;
}
.tb-single-switch-overlay {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px;
right: 12px;
}
.tb-single-switch-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.tb-single-switch-error-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.tb-single-switch-error-panel {
display: flex;
padding: 4px 4px 4px 12px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 4px;
background-color: #fff2f3;
box-shadow: -2px 2px 4px 0px rgba(0,0,0,0.2);
.tb-single-switch-error-text {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
color: rgba(209, 39, 48, 1);
}
.tb-single-switch-error-clear {
color: rgba(209, 39, 48, 1);
}
}
}
> div.tb-single-switch-title-panel {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
z-index: 2;
}
.tb-single-switch-content {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
gap: 8px;
&.right {
flex-direction: row;
justify-content: space-between;
}
&.no-label.right {
justify-content: flex-end;
}
&.left {
flex-direction: row-reverse;
justify-content: flex-end;
}
&.centered {
flex-direction: row;
justify-content: center;
}
.tb-single-switch-label-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
.tb-single-switch-label {
white-space: nowrap;
}
}
&:not(.auto-scale) {
.tb-single-switch-label-row {
overflow: hidden;
.tb-single-switch-label {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.tb-single-switch-slide-toggle-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.mat-mdc-slide-toggle {
&.tb-single-switch-toggle {
.mdc-switch {
width: 42px;
height: 22px;
.mdc-switch__track {
height: 22px;
border-radius: 11px;
&::after, &::before {
border: none;
}
}
.mdc-switch__handle-track {
width: calc(100% - 22px);
left: 2px;
}
.mdc-switch__handle {
border-radius: 9px;
width: 18px;
height: 18px;
}
.mdc-switch__ripple {
width: 36px;
height: 36px;
}
.mdc-switch__icons {
display: none;
}
&:disabled {
.mdc-switch__track {
opacity: 1;
&::after, &::before {
background: $switchColorDisabled;
}
}
.mdc-switch__handle::after {
opacity: 1;
background: $tumblerColorDisabled;
}
}
&:enabled, &:enabled:hover {
.mdc-switch__track {
&::after {
background: $switchColorOn;
}
&::before {
background: $switchColorOff;
}
}
&.mdc-switch--selected {
.mdc-switch__handle::after {
background: $tumblerColorOn;
}
.mdc-switch__ripple::after {
background-color: $switchColorOn;
}
}
&.mdc-switch--unselected {
.mdc-switch__handle::after {
background: $tumblerColorOff;
}
.mdc-switch__ripple::after {
background-color: $switchColorOff;
}
}
}
}
.mdc-form-field > label {
display: none;
}
}
}
}
}

202
ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts

@ -0,0 +1,202 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
OnInit,
Renderer2,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { BasicRpcStateWidgetComponent } from '@home/components/widget/lib/rpc/rpc-widget.models';
import {
singleSwitchDefaultSettings,
SingleSwitchLayout,
SingleSwitchWidgetSettings
} from '@home/components/widget/lib/rpc/single-switch-widget.models';
import { ComponentStyle, iconStyle, textStyle } from '@shared/models/widget-settings.models';
import { Observable } from 'rxjs';
import { ResizeObserver } from '@juggle/resize-observer';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
import cssjs from '@core/css/css';
import { hashCode } from '@core/utils';
import { RpcInitialStateSettings, RpcUpdateStateSettings } from '@shared/models/rpc-widget-settings.models';
import { ValueType } from '@shared/models/constants';
const horizontalLayoutPadding = 48;
const verticalLayoutPadding = 36;
@Component({
selector: 'tb-single-switch-widget',
templateUrl: './single-switch-widget.component.html',
styleUrls: ['./single-switch-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SingleSwitchWidgetComponent extends
BasicRpcStateWidgetComponent<boolean, SingleSwitchWidgetSettings> implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('singleSwitchPanel', {static: false})
singleSwitchPanel: ElementRef<HTMLElement>;
@ViewChild('singleSwitchContent', {static: false})
singleSwitchContent: ElementRef<HTMLElement>;
@ViewChild('singleSwitchLabelRow', {static: false})
singleSwitchLabelRow: ElementRef<HTMLElement>;
@ViewChild('singleSwitchToggleRow', {static: false})
singleSwitchToggleRow: ElementRef<HTMLElement>;
layout: SingleSwitchLayout;
showIcon = false;
icon = '';
iconStyle: ComponentStyle = {};
showLabel = true;
label$: Observable<string>;
labelStyle: ComponentStyle = {};
showOnLabel = false;
onLabel = '';
onLabelStyle: ComponentStyle = {};
showOffLabel = false;
offLabel = '';
offLabelStyle: ComponentStyle = {};
autoScale = false;
private panelResize$: ResizeObserver;
constructor(protected imagePipe: ImagePipe,
protected sanitizer: DomSanitizer,
private renderer: Renderer2,
protected cd: ChangeDetectorRef,
private elementRef: ElementRef) {
super(imagePipe, sanitizer, cd);
}
ngOnInit(): void {
super.ngOnInit();
this.layout = this.settings.layout;
this.autoScale = this.settings.autoScale;
this.showLabel = this.settings.showLabel;
this.label$ = this.ctx.registerLabelPattern(this.settings.label, this.label$);
this.labelStyle = textStyle(this.settings.labelFont);
this.labelStyle.color = this.settings.labelColor;
this.showIcon = this.settings.showIcon;
this.icon = this.settings.icon;
this.iconStyle = iconStyle(this.settings.iconSize, this.settings.iconSizeUnit );
this.iconStyle.color = this.settings.iconColor;
this.showOnLabel = this.settings.showOnLabel;
this.onLabel = this.settings.onLabel;
this.onLabelStyle = textStyle(this.settings.onLabelFont);
this.onLabelStyle.color = this.settings.onLabelColor;
this.showOffLabel = this.settings.showOffLabel;
this.offLabel = this.settings.offLabel;
this.offLabelStyle = textStyle(this.settings.offLabelFont);
this.offLabelStyle.color = this.settings.offLabelColor;
const switchVariablesCss = `.tb-single-switch-panel {\n`+
`--tb-single-switch-tumbler-color-on: ${this.settings.tumblerColorOn};\n`+
`--tb-single-switch-tumbler-color-off: ${this.settings.tumblerColorOff};\n`+
`--tb-single-switch-tumbler-color-disabled: ${this.settings.tumblerColorDisabled};\n`+
`--tb-single-switch-color-on: ${this.settings.switchColorOn};\n`+
`--tb-single-switch-color-off: ${this.settings.switchColorOff};\n`+
`--tb-single-switch-color-disabled: ${this.settings.switchColorDisabled};\n`+
`}`;
const cssParser = new cssjs();
cssParser.testMode = false;
const namespace = 'single-switch-' + hashCode(switchVariablesCss);
cssParser.cssPreviewNamespace = namespace;
cssParser.createStyleElement(namespace, switchVariablesCss);
this.renderer.addClass(this.elementRef.nativeElement, namespace);
}
ngAfterViewInit(): void {
if (this.autoScale) {
this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'overflow', 'visible');
this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'position', 'absolute');
this.panelResize$ = new ResizeObserver(() => {
this.onResize();
});
this.panelResize$.observe(this.singleSwitchPanel.nativeElement);
if (this.showLabel) {
this.panelResize$.observe(this.singleSwitchLabelRow.nativeElement);
}
this.onResize();
}
super.ngAfterViewInit();
}
ngOnDestroy() {
if (this.panelResize$) {
this.panelResize$.disconnect();
}
}
protected stateValueType(): ValueType {
return ValueType.BOOLEAN;
}
protected defaultValue(): boolean {
return false;
}
protected defaultSettings(): SingleSwitchWidgetSettings {
return {...singleSwitchDefaultSettings};
}
protected initialState(): RpcInitialStateSettings<boolean> {
return {...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.initial-state')};
}
protected getUpdateStateSettingsForValue(value: boolean): RpcUpdateStateSettings {
const targetSettings = value ? this.settings.onUpdateState : this.settings.offUpdateState;
return {...targetSettings, actionLabel: this.ctx.translate.instant(value ? 'widgets.rpc-state.turn-on' : 'widgets.rpc-state.turn-off')};
}
protected validateValue(value: any): boolean {
return !!value;
}
private onResize() {
const panelWidth = this.singleSwitchPanel.nativeElement.getBoundingClientRect().width - horizontalLayoutPadding;
const panelHeight = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height - verticalLayoutPadding;
this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(1)`);
let contentWidth = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().width;
let contentHeight = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().height;
if (this.showIcon || this.showLabel) {
contentWidth += (8 + this.singleSwitchLabelRow.nativeElement.getBoundingClientRect().width);
contentHeight = Math.max(contentHeight, this.singleSwitchLabelRow.nativeElement.getBoundingClientRect().height);
}
const scale = Math.min(panelWidth / contentWidth, panelHeight / contentHeight);
const width = panelWidth / scale;
this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'width', width + 'px');
this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(${scale})`);
}
}

202
ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts

@ -0,0 +1,202 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import {
RpcDataToStateType,
RpcInitialStateAction,
RpcStateToParamsType,
RpcStateWidgetSettings,
RpcUpdateStateAction,
RpcUpdateStateSettings
} from '@shared/models/rpc-widget-settings.models';
export enum SingleSwitchLayout {
right = 'right',
left = 'left',
centered = 'centered'
}
export const singleSwitchLayouts = Object.keys(SingleSwitchLayout) as SingleSwitchLayout[];
export const singleSwitchLayoutTranslations = new Map<SingleSwitchLayout, string>(
[
[SingleSwitchLayout.right, 'widgets.single-switch.layout-right'],
[SingleSwitchLayout.left, 'widgets.single-switch.layout-left'],
[SingleSwitchLayout.centered, 'widgets.single-switch.layout-centered']
]
);
export const singleSwitchLayoutImages = new Map<SingleSwitchLayout, string>(
[
[SingleSwitchLayout.right, 'assets/widget/single-switch/right-layout.svg'],
[SingleSwitchLayout.left, 'assets/widget/single-switch/left-layout.svg'],
[SingleSwitchLayout.centered, 'assets/widget/single-switch/centered-layout.svg']
]
);
export interface SingleSwitchWidgetSettings extends RpcStateWidgetSettings<boolean> {
onUpdateState: RpcUpdateStateSettings;
offUpdateState: RpcUpdateStateSettings;
layout: SingleSwitchLayout;
autoScale: boolean;
showLabel: boolean;
label: string;
labelFont: Font;
labelColor: string;
showIcon: boolean;
icon: string;
iconSize: number;
iconSizeUnit: cssUnit;
iconColor: string;
switchColorOn: string;
switchColorOff: string;
switchColorDisabled: string;
tumblerColorOn: string;
tumblerColorOff: string;
tumblerColorDisabled: string;
showOnLabel: boolean;
onLabel: string;
onLabelFont: Font;
onLabelColor: string;
showOffLabel: boolean;
offLabel: string;
offLabelFont: Font;
offLabelColor: string;
}
export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = {
initialState: {
action: RpcInitialStateAction.EXECUTE_RPC,
defaultValue: false,
executeRpc: {
method: 'getState',
requestTimeout: 5000,
requestPersistent: false,
persistentPollingInterval: 1000
},
getAttribute: {
key: 'state',
scope: null
},
getTimeSeries: {
key: 'state'
},
dataToState: {
type: RpcDataToStateType.NONE,
compareToValue: true,
dataToStateFunction: '/* Should return boolean value */\nreturn data;'
}
},
onUpdateState: {
action: RpcUpdateStateAction.EXECUTE_RPC,
executeRpc: {
method: 'setState',
requestTimeout: 5000,
requestPersistent: false,
persistentPollingInterval: 1000
},
setAttribute: {
key: 'state',
scope: AttributeScope.SHARED_SCOPE
},
putTimeSeries: {
key: 'state'
},
stateToParams: {
type: RpcStateToParamsType.CONSTANT,
constantValue: true,
stateToParamsFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;'
}
},
offUpdateState: {
action: RpcUpdateStateAction.EXECUTE_RPC,
executeRpc: {
method: 'setState',
requestTimeout: 5000,
requestPersistent: false,
persistentPollingInterval: 1000
},
setAttribute: {
key: 'state',
scope: AttributeScope.SHARED_SCOPE
},
putTimeSeries: {
key: 'state'
},
stateToParams: {
type: RpcStateToParamsType.CONSTANT,
constantValue: false,
stateToParamsFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */ \n return value;'
}
},
layout: SingleSwitchLayout.right,
autoScale: true,
showLabel: true,
label: 'Switch',
labelFont: {
family: 'Roboto',
size: 16,
sizeUnit: 'px',
style: 'normal',
weight: '500',
lineHeight: '24px'
},
labelColor: 'rgba(0, 0, 0, 0.76)',
showIcon: false,
icon: 'mdi:lightbulb-outline',
iconSize: 24,
iconSizeUnit: 'px',
iconColor: 'rgba(0, 0, 0, 0.76)',
switchColorOn: '#5469FF',
switchColorOff: 'rgba(84, 105, 255, 0.30)',
switchColorDisabled: '#D5D7E5',
tumblerColorOn: '#fff',
tumblerColorOff: '#fff',
tumblerColorDisabled: '#fff',
showOnLabel: false,
onLabel: 'On',
onLabelFont: {
family: 'Roboto',
size: 16,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '24px'
},
onLabelColor: 'rgba(0, 0, 0, 0.38)',
showOffLabel: false,
offLabel: 'Off',
offLabelFont: {
family: 'Roboto',
size: 16,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '24px'
},
offLabelColor: 'rgba(0, 0, 0, 0.38)',
background: {
type: BackgroundType.color,
color: '#fff',
overlay: {
enabled: false,
color: 'rgba(255,255,255,0.72)',
blur: 3
}
}
};

211
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.html

@ -0,0 +1,211 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-rpc-state-settings-panel" [formGroup]="initialStateSettingsFormGroup">
<div class="tb-rpc-state-settings-title" translate>widgets.rpc-state.initial-state</div>
<div class="tb-rpc-state-settings-panel-content">
<div class="tb-form-row">
<div class="fixed-title-width" >{{ 'widgets.rpc-state.action' | translate }}</div>
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="action">
<mat-option *ngFor="let action of rpcInitialStateActions" [value]="action">
{{ rpcInitialStateTranslationsMap.get(action) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<ng-container [ngSwitch]="initialStateSettingsFormGroup.get('action').value">
<ng-template [ngSwitchCase]="rpcInitialStateAction.DO_NOTHING">
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{'widgets.rpc-state.init-value-hint' | translate}}" translate>widgets.rpc-state.value</div>
<tb-value-input [valueType]="stateValueType"
trueLabel="widgets.rpc-state.on"
falseLabel="widgets.rpc-state.off"
formControlName="defaultValue"></tb-value-input>
</div>
</ng-template>
<ng-template [ngSwitchCase]="rpcInitialStateAction.EXECUTE_RPC">
<div class="tb-form-row" formGroupName="executeRpc">
<div class="fixed-title-width" >{{ 'widgets.rpc-state.method' | translate }}*</div>
<mat-form-field class="flex tb-suffix-absolute" appearance="outline" subscriptSizing="dynamic">
<input matInput required formControlName="method" placeholder="{{ 'widget-config.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'widgets.rpc-state.method-name-required' | translate"
*ngIf="initialStateSettingsFormGroup.get('executeRpc').get('method').hasError('required')
&& initialStateSettingsFormGroup.get('executeRpc').get('method').touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
</ng-template>
<ng-template [ngSwitchCase]="rpcInitialStateAction.GET_ATTRIBUTE">
<ng-container formGroupName="getAttribute">
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'widgets.rpc-state.attribute-scope' | translate }}</div>
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="scope" placeholder="{{ 'attribute.scope-any' | translate }}">
<mat-option [value]="null">
{{ 'attribute.scope-any' | translate }}
</mat-option>
<mat-option *ngFor="let scope of attributeScopes" [value]="scope">
{{ telemetryTypeTranslationsMap.get(scope) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" >{{ 'widgets.rpc-state.attribute-key' | translate }}*</div>
<tb-device-key-autocomplete
fxFlex
formControlName="key"
required
requiredText="widgets.rpc-state.attribute-key-required"
inlineField
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[keyType]="dataKeyType.attribute"
[attributeScope]="initialStateSettingsFormGroup.get('getAttribute').get('scope').value">
</tb-device-key-autocomplete>
</div>
</ng-container>
</ng-template>
<ng-template [ngSwitchCase]="rpcInitialStateAction.GET_TIME_SERIES">
<ng-container formGroupName="getTimeSeries">
<div class="tb-form-row">
<div class="fixed-title-width" >{{ 'widgets.rpc-state.time-series-key' | translate }}*</div>
<tb-device-key-autocomplete
fxFlex
formControlName="key"
required
requiredText="widgets.rpc-state.time-series-key-required"
inlineField
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[keyType]="dataKeyType.timeseries">
</tb-device-key-autocomplete>
</div>
</ng-container>
</ng-template>
</ng-container>
<div *ngIf="initialStateSettingsFormGroup.get('action').value !== rpcInitialStateAction.DO_NOTHING"
class="tb-form-panel stroked" formGroupName="dataToState">
<div class="tb-form-row no-padding no-border column-xs">
<div class="fixed-title-width" translate>widgets.rpc-state.action-result-converter</div>
<tb-toggle-select fxFlex formControlName="type">
<tb-toggle-option [value]="dataToStateType.NONE">{{ 'widgets.rpc-state.converter-none' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="dataToStateType.FUNCTION">{{ 'widgets.rpc-state.converter-function' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-js-func *ngIf="initialStateSettingsFormGroup.get('dataToState').get('type').value === dataToStateType.FUNCTION"
formControlName="dataToStateFunction"
required
[globalVariables]="functionScopeVariables"
[functionArgs]="['data']"
functionTitle="{{ 'widgets.rpc-state.parse-value-function' | translate }}"
helpId="widget/lib/rpc/parse_value_fn">
</tb-js-func>
<div *ngIf="stateValueType === valueType.BOOLEAN" class="tb-form-row align-start no-gap column-xs">
<div class="fixed-title-width fixed-title-height" translate>widgets.rpc-state.on-when-result-is</div>
<tb-value-input
fxFlex
layout="column"
formControlName="compareToValue">
</tb-value-input>
</div>
</div>
<div *ngIf="initialStateSettingsFormGroup.get('action').value === rpcInitialStateAction.EXECUTE_RPC"
class="tb-form-panel no-border tb-slide-toggle" formGroupName="executeRpc">
<mat-expansion-panel class="tb-settings no-padding">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
</mat-panel-title>
<mat-panel-description fxLayoutAlign="end center" translate>
widget-config.advanced-settings
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between">
<div>{{ 'widgets.rpc-state.request-timeout-ms' | translate }}</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="requestTimeout" type="number" required min="5000" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="(initialStateSettingsFormGroup.get('executeRpc').get('requestTimeout').hasError('required')
? 'widgets.rpc-state.request-timeout-required' : 'widgets.rpc-state.min-request-timeout-error') | translate"
*ngIf="initialStateSettingsFormGroup.get('executeRpc').get('requestTimeout').touched &&
initialStateSettingsFormGroup.get('executeRpc').get('requestTimeout').invalid"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings"
[expanded]="initialStateSettingsFormGroup.get('executeRpc').get('requestPersistent').value"
[disabled]="!initialStateSettingsFormGroup.get('executeRpc').get('requestPersistent').value">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide" formControlName="requestPersistent" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'widgets.rpc-state.request-persistent' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{'widgets.rpc-state.persistent-polling-interval-hint' | translate}}">{{ 'widgets.rpc-state.persistent-polling-interval' | translate }}</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="persistentPollingInterval" type="number" required min="1000" placeholder="{{ 'widget-config.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="(initialStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').hasError('required')
? 'widgets.rpc-state.persistent-polling-interval-required' : 'widgets.rpc-state.min-persistent-polling-interval-error') | translate"
*ngIf="initialStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').touched &&
initialStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').invalid"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</div>
<div class="tb-rpc-state-settings-panel-buttons">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyInitialStateSettings()"
[disabled]="initialStateSettingsFormGroup.invalid || !initialStateSettingsFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

176
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.ts

@ -0,0 +1,176 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { merge } from 'rxjs';
import {
RpcDataToStateType,
RpcInitialStateAction,
rpcInitialStateActions,
RpcInitialStateSettings,
rpcInitialStateTranslations
} from '@shared/models/rpc-widget-settings.models';
import { ValueType } from '@shared/models/constants';
import { TargetDevice } from '@shared/models/widget.models';
import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models';
import { IAliasController } from '@core/api/widget-api.models';
import { WidgetService } from '@core/http/widget.service';
@Component({
selector: 'tb-rpc-initial-state-settings-panel',
templateUrl: './rpc-initial-state-settings-panel.component.html',
providers: [],
styleUrls: ['./rpc-state-settings-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class RpcInitialStateSettingsPanelComponent extends PageComponent implements OnInit {
@Input()
initialState: RpcInitialStateSettings<any>;
@Input()
stateValueType: ValueType;
@Input()
aliasController: IAliasController;
@Input()
targetDevice: TargetDevice;
@Input()
popover: TbPopoverComponent<RpcInitialStateSettingsPanelComponent>;
@Output()
initialStateSettingsApplied = new EventEmitter<RpcInitialStateSettings<any>>();
rpcInitialStateAction = RpcInitialStateAction;
rpcInitialStateActions = rpcInitialStateActions;
rpcInitialStateTranslationsMap = rpcInitialStateTranslations;
telemetryTypeTranslationsMap = telemetryTypeTranslationsShort;
attributeScopes = Object.keys(AttributeScope) as AttributeScope[];
dataKeyType = DataKeyType;
dataToStateType = RpcDataToStateType;
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
valueType = ValueType;
initialStateSettingsFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder,
private widgetService: WidgetService,
protected store: Store<AppState>) {
super(store);
}
ngOnInit(): void {
this.initialStateSettingsFormGroup = this.fb.group(
{
action: [this.initialState?.action, []],
defaultValue: [this.initialState?.defaultValue, [Validators.required]],
executeRpc: this.fb.group({
method: [this.initialState?.executeRpc?.method, [Validators.required]],
requestTimeout: [this.initialState?.executeRpc?.requestTimeout, [Validators.required, Validators.min(5000)]],
requestPersistent: [this.initialState?.executeRpc?.requestPersistent, []],
persistentPollingInterval: [this.initialState?.executeRpc?.persistentPollingInterval, [Validators.required, Validators.min(1000)]]
}),
getAttribute: this.fb.group({
scope: [this.initialState?.getAttribute?.scope, []],
key: [this.initialState?.getAttribute?.key, [Validators.required]],
}),
getTimeSeries: this.fb.group({
key: [this.initialState?.getTimeSeries?.key, [Validators.required]],
}),
dataToState: this.fb.group({
type: [this.initialState?.dataToState?.type, [Validators.required]],
dataToStateFunction: [this.initialState?.dataToState?.dataToStateFunction, [Validators.required]],
}),
}
);
if (this.stateValueType === ValueType.BOOLEAN) {
(this.initialStateSettingsFormGroup.get('dataToState') as UntypedFormGroup).addControl(
'compareToValue', this.fb.control(this.initialState?.dataToState?.compareToValue, [Validators.required])
);
}
merge(this.initialStateSettingsFormGroup.get('action').valueChanges,
this.initialStateSettingsFormGroup.get('dataToState').get('type').valueChanges,
this.initialStateSettingsFormGroup.get('executeRpc').get('requestPersistent').valueChanges).subscribe(() => {
this.updateValidators();
});
this.updateValidators();
}
cancel() {
this.popover?.hide();
}
applyInitialStateSettings() {
const initialStateSettings: RpcInitialStateSettings<any> = this.initialStateSettingsFormGroup.getRawValue();
this.initialStateSettingsApplied.emit(initialStateSettings);
}
private updateValidators() {
const action: RpcInitialStateAction = this.initialStateSettingsFormGroup.get('action').value;
const dataToStateType: RpcDataToStateType = this.initialStateSettingsFormGroup.get('dataToState').get('type').value;
this.initialStateSettingsFormGroup.get('defaultValue').disable({emitEvent: false});
this.initialStateSettingsFormGroup.get('executeRpc').disable({emitEvent: false});
this.initialStateSettingsFormGroup.get('getAttribute').disable({emitEvent: false});
this.initialStateSettingsFormGroup.get('getTimeSeries').disable({emitEvent: false});
switch (action) {
case RpcInitialStateAction.DO_NOTHING:
this.initialStateSettingsFormGroup.get('defaultValue').enable({emitEvent: false});
break;
case RpcInitialStateAction.EXECUTE_RPC:
this.initialStateSettingsFormGroup.get('executeRpc').enable({emitEvent: false});
const requestPersistent: boolean = this.initialStateSettingsFormGroup.get('executeRpc').get('requestPersistent').value;
if (requestPersistent) {
this.initialStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').enable({emitEvent: false});
} else {
this.initialStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').disable({emitEvent: false});
}
break;
case RpcInitialStateAction.GET_ATTRIBUTE:
this.initialStateSettingsFormGroup.get('getAttribute').enable({emitEvent: false});
break;
case RpcInitialStateAction.GET_TIME_SERIES:
this.initialStateSettingsFormGroup.get('getTimeSeries').enable({emitEvent: false});
break;
}
if (action === RpcInitialStateAction.DO_NOTHING) {
this.initialStateSettingsFormGroup.get('dataToState').disable({emitEvent: false});
} else {
this.initialStateSettingsFormGroup.get('dataToState').enable({emitEvent: false});
if (dataToStateType === RpcDataToStateType.FUNCTION) {
this.initialStateSettingsFormGroup.get('dataToState').get('dataToStateFunction').enable({emitEvent: false});
} else {
this.initialStateSettingsFormGroup.get('dataToState').get('dataToStateFunction').disable({emitEvent: false});
}
}
}
}

156
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings.component.ts

@ -0,0 +1,156 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
ChangeDetectorRef,
Component,
forwardRef,
HostBinding,
Input,
OnInit,
Renderer2,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { RpcInitialStateAction, RpcInitialStateSettings } from '@shared/models/rpc-widget-settings.models';
import { TranslateService } from '@ngx-translate/core';
import { ValueType } from '@shared/models/constants';
import {
RpcInitialStateSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component';
import { IAliasController } from '@core/api/widget-api.models';
import { TargetDevice } from '@shared/models/widget.models';
@Component({
selector: 'tb-rpc-initial-state-settings',
templateUrl: './rpc-state-settings-button.component.html',
styleUrls: ['./rpc-state-settings-button.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RpcInitialStateSettingsComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class RpcInitialStateSettingsComponent implements OnInit, ControlValueAccessor {
@HostBinding('style.overflow')
overflow = 'hidden';
@Input()
stateValueType: ValueType;
@Input()
aliasController: IAliasController;
@Input()
targetDevice: TargetDevice;
@Input()
disabled = false;
modelValue: RpcInitialStateSettings<any>;
displayValue: string;
private propagateChange = null;
constructor(private translate: TranslateService,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef) {}
ngOnInit(): void {
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
if (this.disabled !== isDisabled) {
this.disabled = isDisabled;
}
}
writeValue(value: RpcInitialStateSettings<any>): void {
this.modelValue = value;
this.updateDisplayValue();
}
openRpcStateSettingsPopup($event: Event, matButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
initialState: this.modelValue,
stateValueType: this.stateValueType,
aliasController: this.aliasController,
targetDevice: this.targetDevice
};
const initialStateSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, RpcInitialStateSettingsPanelComponent,
['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null,
ctx,
{},
{}, {}, true);
initialStateSettingsPanelPopover.tbComponentRef.instance.popover = initialStateSettingsPanelPopover;
initialStateSettingsPanelPopover.tbComponentRef.instance.initialStateSettingsApplied.subscribe((initialState) => {
initialStateSettingsPanelPopover.hide();
this.modelValue = initialState;
this.updateDisplayValue();
this.propagateChange(this.modelValue);
});
}
}
private updateDisplayValue() {
switch (this.modelValue.action) {
case RpcInitialStateAction.DO_NOTHING:
if (this.stateValueType === ValueType.BOOLEAN) {
this.displayValue = this.translate.instant(!!this.modelValue.defaultValue ? 'widgets.rpc-state.on' : 'widgets.rpc-state.off');
} else {
this.displayValue = this.modelValue.defaultValue + '';
}
break;
case RpcInitialStateAction.EXECUTE_RPC:
const methodName = this.modelValue.executeRpc.method;
this.displayValue = this.translate.instant('widgets.rpc-state.execute-rpc-text', {methodName});
break;
case RpcInitialStateAction.GET_ATTRIBUTE:
this.displayValue = this.translate.instant('widgets.rpc-state.get-attribute-text', {key: this.modelValue.getAttribute.key});
break;
case RpcInitialStateAction.GET_TIME_SERIES:
this.displayValue = this.translate.instant('widgets.rpc-state.get-time-series-text', {key: this.modelValue.getTimeSeries.key});
break;
}
this.cd.markForCheck();
}
}

26
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.component.html

@ -0,0 +1,26 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<button #matButton [disabled]="disabled"
class="tb-rpc-state-settings"
mat-stroked-button color="primary"
(click)="openRpcStateSettingsPopup($event, matButton)">
<div>
<span>{{ displayValue }}</span>
<mat-icon class="tb-mat-20">edit</mat-icon>
</div>
</button>

34
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.scss

@ -0,0 +1,34 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.mdc-button.tb-rpc-state-settings {
width: 100%;
.mdc-button__label {
width: 100%;
& > div {
width: 100%;
display: flex;
flex-direction: row;
gap: 8px;
justify-content: space-between;
align-items: center;
& > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}

47
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-panel.component.scss

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

201
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.html

@ -0,0 +1,201 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-rpc-state-settings-panel" [formGroup]="updateStateSettingsFormGroup">
<div class="tb-rpc-state-settings-title">{{ panelTitle | translate }}</div>
<div class="tb-rpc-state-settings-panel-content">
<div class="tb-form-row">
<div class="fixed-title-width" >{{ 'widgets.rpc-state.action' | translate }}</div>
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="action">
<mat-option *ngFor="let action of rpcUpdateStateActions" [value]="action">
{{ rpcUpdateStateTranslationsMap.get(action) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<ng-container [ngSwitch]="updateStateSettingsFormGroup.get('action').value">
<ng-template [ngSwitchCase]="rpcUpdateStateAction.EXECUTE_RPC">
<div class="tb-form-row" formGroupName="executeRpc">
<div class="fixed-title-width" >{{ 'widgets.rpc-state.method' | translate }}*</div>
<mat-form-field class="flex tb-suffix-absolute" appearance="outline" subscriptSizing="dynamic">
<input matInput required formControlName="method" placeholder="{{ 'widget-config.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'widgets.rpc-state.method-name-required' | translate"
*ngIf="updateStateSettingsFormGroup.get('executeRpc').get('method').hasError('required')
&& updateStateSettingsFormGroup.get('executeRpc').get('method').touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
</ng-template>
<ng-template [ngSwitchCase]="rpcUpdateStateAction.SET_ATTRIBUTE">
<ng-container formGroupName="setAttribute">
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'widgets.rpc-state.attribute-scope' | translate }}</div>
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="scope">
<mat-option *ngFor="let scope of attributeScopes" [value]="scope">
{{ telemetryTypeTranslationsMap.get(scope) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" >{{ 'widgets.rpc-state.attribute-key' | translate }}*</div>
<tb-device-key-autocomplete
fxFlex
formControlName="key"
required
requiredText="widgets.rpc-state.attribute-key-required"
inlineField
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[keyType]="dataKeyType.attribute"
[attributeScope]="updateStateSettingsFormGroup.get('setAttribute').get('scope').value">
</tb-device-key-autocomplete>
</div>
</ng-container>
</ng-template>
<ng-template [ngSwitchCase]="rpcUpdateStateAction.ADD_TIME_SERIES">
<ng-container formGroupName="putTimeSeries">
<div class="tb-form-row">
<div class="fixed-title-width" >{{ 'widgets.rpc-state.time-series-key' | translate }}*</div>
<tb-device-key-autocomplete
fxFlex
formControlName="key"
required
requiredText="widgets.rpc-state.time-series-key-required"
inlineField
[aliasController]="aliasController"
[targetDevice]="targetDevice"
[keyType]="dataKeyType.timeseries">
</tb-device-key-autocomplete>
</div>
</ng-container>
</ng-template>
</ng-container>
<div class="tb-form-panel stroked" formGroupName="stateToParams">
<div class="tb-form-row no-padding no-border column-xs">
<div class="fixed-title-width">{{ (updateStateSettingsFormGroup.get('action').value === rpcUpdateStateAction.EXECUTE_RPC ?
'widgets.rpc-state.parameters' : 'widgets.rpc-state.value') | translate }}</div>
<tb-toggle-select fxFlex formControlName="type">
<tb-toggle-option [value]="stateToParamsType.CONSTANT">{{ 'widgets.rpc-state.converter-constant' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="stateToParamsType.FUNCTION">{{ 'widgets.rpc-state.converter-function' | translate }}</tb-toggle-option>
<tb-toggle-option *ngIf="updateStateSettingsFormGroup.get('action').value === rpcUpdateStateAction.EXECUTE_RPC"
[value]="stateToParamsType.NONE">{{ 'widgets.rpc-state.converter-none' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-value-input *ngIf="updateStateSettingsFormGroup.get('stateToParams').get('type').value === stateToParamsType.CONSTANT"
fxFlex
[layout]="{
layout: 'row',
breakpoints: {xs: 'column'}
}"
formControlName="constantValue">
</tb-value-input>
<tb-js-func *ngIf="updateStateSettingsFormGroup.get('stateToParams').get('type').value === stateToParamsType.FUNCTION"
formControlName="stateToParamsFunction"
required
[globalVariables]="functionScopeVariables"
[functionArgs]="['value']"
functionTitle="{{ 'widgets.rpc-state.convert-value-function' | translate }}"
helpId="widget/lib/rpc/convert_value_fn">
</tb-js-func>
</div>
<div *ngIf="updateStateSettingsFormGroup.get('action').value === rpcUpdateStateAction.EXECUTE_RPC"
class="tb-form-panel no-border tb-slide-toggle" formGroupName="executeRpc">
<mat-expansion-panel class="tb-settings no-padding">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
</mat-panel-title>
<mat-panel-description fxLayoutAlign="end center" translate>
widget-config.advanced-settings
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between">
<div>{{ 'widgets.rpc-state.request-timeout-ms' | translate }}</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="requestTimeout" type="number" required min="5000" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="(updateStateSettingsFormGroup.get('executeRpc').get('requestTimeout').hasError('required')
? 'widgets.rpc-state.request-timeout-required' : 'widgets.rpc-state.min-request-timeout-error') | translate"
*ngIf="updateStateSettingsFormGroup.get('executeRpc').get('requestTimeout').touched &&
updateStateSettingsFormGroup.get('executeRpc').get('requestTimeout').invalid"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings"
[expanded]="updateStateSettingsFormGroup.get('executeRpc').get('requestPersistent').value"
[disabled]="!updateStateSettingsFormGroup.get('executeRpc').get('requestPersistent').value">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide" formControlName="requestPersistent" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'widgets.rpc-state.request-persistent' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between">
<div tb-hint-tooltip-icon="{{'widgets.rpc-state.persistent-polling-interval-hint' | translate}}">{{ 'widgets.rpc-state.persistent-polling-interval' | translate }}</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="persistentPollingInterval" type="number" required min="1000" placeholder="{{ 'widget-config.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="(updateStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').hasError('required')
? 'widgets.rpc-state.persistent-polling-interval-required' : 'widgets.rpc-state.min-persistent-polling-interval-error') | translate"
*ngIf="updateStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').touched &&
updateStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').invalid"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</div>
<div class="tb-rpc-state-settings-panel-buttons">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyUpdateStateSettings()"
[disabled]="updateStateSettingsFormGroup.invalid || !updateStateSettingsFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

180
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.ts

@ -0,0 +1,180 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { merge } from 'rxjs';
import {
RpcStateToParamsType,
RpcUpdateStateAction,
rpcUpdateStateActions,
RpcUpdateStateSettings,
rpcUpdateStateTranslations
} from '@shared/models/rpc-widget-settings.models';
import { ValueType } from '@shared/models/constants';
import { TargetDevice } from '@shared/models/widget.models';
import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models';
import { IAliasController } from '@core/api/widget-api.models';
import { WidgetService } from '@core/http/widget.service';
@Component({
selector: 'tb-rpc-update-state-settings-panel',
templateUrl: './rpc-update-state-settings-panel.component.html',
providers: [],
styleUrls: ['./rpc-state-settings-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class RpcUpdateStateSettingsPanelComponent extends PageComponent implements OnInit {
@Input()
panelTitle: string;
@Input()
updateState: RpcUpdateStateSettings;
@Input()
stateValueType: ValueType;
@Input()
aliasController: IAliasController;
@Input()
targetDevice: TargetDevice;
@Input()
popover: TbPopoverComponent<RpcUpdateStateSettingsPanelComponent>;
@Output()
updateStateSettingsApplied = new EventEmitter<RpcUpdateStateSettings>();
rpcUpdateStateAction = RpcUpdateStateAction;
rpcUpdateStateActions = rpcUpdateStateActions;
rpcUpdateStateTranslationsMap = rpcUpdateStateTranslations;
telemetryTypeTranslationsMap = telemetryTypeTranslationsShort;
attributeScopes = [AttributeScope.SERVER_SCOPE, AttributeScope.SHARED_SCOPE];
dataKeyType = DataKeyType;
stateToParamsType = RpcStateToParamsType;
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
valueType = ValueType;
updateStateSettingsFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder,
private widgetService: WidgetService,
protected store: Store<AppState>) {
super(store);
}
ngOnInit(): void {
this.updateStateSettingsFormGroup = this.fb.group(
{
action: [this.updateState?.action, []],
executeRpc: this.fb.group({
method: [this.updateState?.executeRpc?.method, [Validators.required]],
requestTimeout: [this.updateState?.executeRpc?.requestTimeout, [Validators.required, Validators.min(5000)]],
requestPersistent: [this.updateState?.executeRpc?.requestPersistent, []],
persistentPollingInterval: [this.updateState?.executeRpc?.persistentPollingInterval, [Validators.required, Validators.min(1000)]]
}),
setAttribute: this.fb.group({
scope: [this.updateState?.setAttribute?.scope, []],
key: [this.updateState?.setAttribute?.key, [Validators.required]],
}),
putTimeSeries: this.fb.group({
key: [this.updateState?.putTimeSeries?.key, [Validators.required]],
}),
stateToParams: this.fb.group({
type: [this.updateState?.stateToParams?.type, [Validators.required]],
constantValue: [this.updateState?.stateToParams?.constantValue, [Validators.required]],
stateToParamsFunction: [this.updateState?.stateToParams?.stateToParamsFunction, [Validators.required]],
}),
}
);
merge(this.updateStateSettingsFormGroup.get('action').valueChanges,
this.updateStateSettingsFormGroup.get('stateToParams').get('type').valueChanges,
this.updateStateSettingsFormGroup.get('executeRpc').get('requestPersistent').valueChanges).subscribe(() => {
this.updateValidators();
});
this.updateValidators();
}
cancel() {
this.popover?.hide();
}
applyUpdateStateSettings() {
const updateStateSettings: RpcUpdateStateSettings = this.updateStateSettingsFormGroup.getRawValue();
this.updateStateSettingsApplied.emit(updateStateSettings);
}
private updateValidators() {
const action: RpcUpdateStateAction = this.updateStateSettingsFormGroup.get('action').value;
let stateToParamsType: RpcStateToParamsType = this.updateStateSettingsFormGroup.get('stateToParams').get('type').value;
this.updateStateSettingsFormGroup.get('executeRpc').disable({emitEvent: false});
this.updateStateSettingsFormGroup.get('setAttribute').disable({emitEvent: false});
this.updateStateSettingsFormGroup.get('putTimeSeries').disable({emitEvent: false});
switch (action) {
case RpcUpdateStateAction.EXECUTE_RPC:
this.updateStateSettingsFormGroup.get('executeRpc').enable({emitEvent: false});
const requestPersistent: boolean = this.updateStateSettingsFormGroup.get('executeRpc').get('requestPersistent').value;
if (requestPersistent) {
this.updateStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').enable({emitEvent: false});
} else {
this.updateStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').disable({emitEvent: false});
}
break;
case RpcUpdateStateAction.SET_ATTRIBUTE:
case RpcUpdateStateAction.ADD_TIME_SERIES:
if (stateToParamsType === RpcStateToParamsType.NONE) {
stateToParamsType = RpcStateToParamsType.CONSTANT;
this.updateStateSettingsFormGroup.get('stateToParams').get('type').patchValue(stateToParamsType, {emitEvent: false});
}
if (action === RpcUpdateStateAction.SET_ATTRIBUTE) {
this.updateStateSettingsFormGroup.get('setAttribute').enable({emitEvent: false});
} else {
this.updateStateSettingsFormGroup.get('putTimeSeries').enable({emitEvent: false});
}
break;
}
switch (stateToParamsType) {
case RpcStateToParamsType.CONSTANT:
this.updateStateSettingsFormGroup.get('stateToParams').get('constantValue').enable({emitEvent: false});
this.updateStateSettingsFormGroup.get('stateToParams').get('stateToParamsFunction').disable({emitEvent: false});
break;
case RpcStateToParamsType.FUNCTION:
this.updateStateSettingsFormGroup.get('stateToParams').get('constantValue').disable({emitEvent: false});
this.updateStateSettingsFormGroup.get('stateToParams').get('stateToParamsFunction').enable({emitEvent: false});
break;
case RpcStateToParamsType.NONE:
this.updateStateSettingsFormGroup.get('stateToParams').get('constantValue').disable({emitEvent: false});
this.updateStateSettingsFormGroup.get('stateToParams').get('stateToParamsFunction').disable({emitEvent: false});
break;
}
}
}

174
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings.component.ts

@ -0,0 +1,174 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
ChangeDetectorRef,
Component,
forwardRef,
HostBinding,
Input,
OnInit,
Renderer2,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import {
RpcStateToParamsType,
RpcUpdateStateAction,
RpcUpdateStateSettings
} from '@shared/models/rpc-widget-settings.models';
import { TranslateService } from '@ngx-translate/core';
import { ValueType } from '@shared/models/constants';
import { IAliasController } from '@core/api/widget-api.models';
import { TargetDevice } from '@shared/models/widget.models';
import { isDefinedAndNotNull } from '@core/utils';
import {
RpcUpdateStateSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component';
@Component({
selector: 'tb-rpc-update-state-settings',
templateUrl: './rpc-state-settings-button.component.html',
styleUrls: ['./rpc-state-settings-button.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RpcUpdateStateSettingsComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class RpcUpdateStateSettingsComponent implements OnInit, ControlValueAccessor {
@HostBinding('style.overflow')
overflow = 'hidden';
@Input()
panelTitle: string;
@Input()
stateValueType: ValueType;
@Input()
aliasController: IAliasController;
@Input()
targetDevice: TargetDevice;
@Input()
disabled = false;
modelValue: RpcUpdateStateSettings;
displayValue: string;
private propagateChange = null;
constructor(private translate: TranslateService,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef) {}
ngOnInit(): void {
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
if (this.disabled !== isDisabled) {
this.disabled = isDisabled;
}
}
writeValue(value: RpcUpdateStateSettings): void {
this.modelValue = value;
this.updateDisplayValue();
}
openRpcStateSettingsPopup($event: Event, matButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
updateState: this.modelValue,
panelTitle: this.panelTitle,
stateValueType: this.stateValueType,
aliasController: this.aliasController,
targetDevice: this.targetDevice
};
const updateStateSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, RpcUpdateStateSettingsPanelComponent,
['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null,
ctx,
{},
{}, {}, true);
updateStateSettingsPanelPopover.tbComponentRef.instance.popover = updateStateSettingsPanelPopover;
updateStateSettingsPanelPopover.tbComponentRef.instance.updateStateSettingsApplied.subscribe((updateState) => {
updateStateSettingsPanelPopover.hide();
this.modelValue = updateState;
this.updateDisplayValue();
this.propagateChange(this.modelValue);
});
}
}
private updateDisplayValue() {
let value: any;
switch (this.modelValue.stateToParams.type) {
case RpcStateToParamsType.CONSTANT:
value = this.modelValue.stateToParams.constantValue;
break;
case RpcStateToParamsType.FUNCTION:
value = 'f(value)';
break;
case RpcStateToParamsType.NONE:
break;
}
switch (this.modelValue.action) {
case RpcUpdateStateAction.EXECUTE_RPC:
let methodName = this.modelValue.executeRpc.method;
if (isDefinedAndNotNull(value)) {
methodName = `${methodName}(${value})`;
}
this.displayValue = this.translate.instant('widgets.rpc-state.execute-rpc-text', {methodName});
break;
case RpcUpdateStateAction.SET_ATTRIBUTE:
this.displayValue = this.translate.instant('widgets.rpc-state.set-attribute-to-value-text',
{key: this.modelValue.setAttribute.key, value});
break;
case RpcUpdateStateAction.ADD_TIME_SERIES:
this.displayValue = this.translate.instant('widgets.rpc-state.add-time-series-value-text',
{key: this.modelValue.setAttribute.key, value});
break;
}
this.cd.markForCheck();
}
}

29
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts

@ -51,6 +51,21 @@ import { ColorRangePanelComponent } from '@home/components/widget/lib/settings/c
import { import {
ColorRangeSettingsComponent, ColorRangeSettingsComponentService ColorRangeSettingsComponent, ColorRangeSettingsComponentService
} from '@home/components/widget/lib/settings/common/color-range-settings.component'; } from '@home/components/widget/lib/settings/common/color-range-settings.component';
import {
RpcInitialStateSettingsComponent
} from '@home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings.component';
import {
RpcInitialStateSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component';
import {
DeviceKeyAutocompleteComponent
} from '@home/components/widget/lib/settings/control/device-key-autocomplete.component';
import {
RpcUpdateStateSettingsComponent
} from '@home/components/widget/lib/settings/common/rpc/rpc-update-state-settings.component';
import {
RpcUpdateStateSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -71,7 +86,12 @@ import {
CountWidgetSettingsComponent, CountWidgetSettingsComponent,
ColorRangeListComponent, ColorRangeListComponent,
ColorRangePanelComponent, ColorRangePanelComponent,
ColorRangeSettingsComponent ColorRangeSettingsComponent,
RpcInitialStateSettingsComponent,
RpcInitialStateSettingsPanelComponent,
DeviceKeyAutocompleteComponent,
RpcUpdateStateSettingsComponent,
RpcUpdateStateSettingsPanelComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -96,7 +116,12 @@ import {
CountWidgetSettingsComponent, CountWidgetSettingsComponent,
ColorRangeListComponent, ColorRangeListComponent,
ColorRangePanelComponent, ColorRangePanelComponent,
ColorRangeSettingsComponent ColorRangeSettingsComponent,
RpcInitialStateSettingsComponent,
RpcInitialStateSettingsPanelComponent,
DeviceKeyAutocompleteComponent,
RpcUpdateStateSettingsComponent,
RpcUpdateStateSettingsPanelComponent
], ],
providers: [ providers: [
ColorSettingsComponentService, ColorSettingsComponentService,

21
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html

@ -15,8 +15,13 @@
limitations under the License. limitations under the License.
--> -->
<mat-form-field fxFlex class="mat-block" [formGroup]="deviceKeyFormGroup"> <mat-form-field fxFlex [appearance]="inlineField ? 'outline': 'fill'"
<mat-label>{{ (keyType === dataKeyType.attribute [subscriptSizing]="inlineField ? 'dynamic' : 'fixed'"
[class]="{'tb-inline-field': inlineField, 'flex': inlineField,
'tb-suffix-absolute': (inlineField && !deviceKeyFormGroup.get('key').value)}"
class="mat-block"
[formGroup]="deviceKeyFormGroup">
<mat-label *ngIf="!inlineField">{{ (keyType === dataKeyType.attribute
? attributeLabel ? attributeLabel
: timeseriesLabel) | translate }}</mat-label> : timeseriesLabel) | translate }}</mat-label>
<input [required]="required" matInput <input [required]="required" matInput
@ -31,6 +36,15 @@
(click)="clearKey()"> (click)="clearKey()">
<mat-icon class="material-icons">close</mat-icon> <mat-icon class="material-icons">close</mat-icon>
</button> </button>
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="requiredText | translate"
*ngIf="inlineField && requiredText && deviceKeyFormGroup.get('key').hasError('required')
&& deviceKeyFormGroup.get('key').touched"
class="tb-error">
warning
</mat-icon>
<mat-autocomplete <mat-autocomplete
class="tb-autocomplete" class="tb-autocomplete"
#keyAutocomplete="matAutocomplete"> #keyAutocomplete="matAutocomplete">
@ -38,4 +52,7 @@
<span [innerHTML]="key | highlight:keySearchText"></span> <span [innerHTML]="key | highlight:keySearchText"></span>
</mat-option> </mat-option>
</mat-autocomplete> </mat-autocomplete>
<mat-error *ngIf="!inlineField && requiredText && deviceKeyFormGroup.get('key').hasError('required')">
{{ requiredText | translate }}
</mat-error>
</mat-form-field> </mat-form-field>

58
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts

@ -25,15 +25,17 @@ import {
import { PageComponent } from '@shared/components/page.component'; import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { TranslateService } from '@ngx-translate/core'; import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { IAliasController } from '@core/api/widget-api.models'; import { IAliasController } from '@core/api/widget-api.models';
import { catchError, map, mergeMap, publishReplay, refCount, tap } from 'rxjs/operators'; import { catchError, map, mergeMap, publishReplay, refCount, tap } from 'rxjs/operators';
import { DataKey } from '@shared/models/widget.models'; import { DataKey, TargetDevice, TargetDeviceType, targetDeviceValid } from '@shared/models/widget.models';
import { EntityService } from '@core/http/entity.service'; import { EntityService } from '@core/http/entity.service';
import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { EntityFilter, singleEntityFilterFromDeviceId } from '@shared/models/query/query.models';
import { AliasFilterType } from '@shared/models/alias.models';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({ @Component({
selector: 'tb-device-key-autocomplete', selector: 'tb-device-key-autocomplete',
@ -58,25 +60,30 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
aliasController: IAliasController; aliasController: IAliasController;
@Input() @Input()
targetDeviceAliasId: string; targetDevice: TargetDevice;
@Input() @Input()
keyType: DataKeyType; keyType: DataKeyType;
@Input()
attributeScope: AttributeScope;
@Input() @Input()
attributeLabel = 'widgets.rpc.attribute-value-key'; attributeLabel = 'widgets.rpc.attribute-value-key';
@Input() @Input()
timeseriesLabel = 'widgets.rpc.timeseries-value-key'; timeseriesLabel = 'widgets.rpc.timeseries-value-key';
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input() @Input()
set required(value: boolean) { requiredText: string;
this.requiredValue = coerceBooleanProperty(value);
} @Input()
@coerceBoolean()
required: boolean;
@Input()
@coerceBoolean()
inlineField: boolean;
dataKeyType = DataKeyType; dataKeyType = DataKeyType;
@ -93,7 +100,6 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
private keysFetchObservable$: Observable<Array<string>> = null; private keysFetchObservable$: Observable<Array<string>> = null;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
private translate: TranslateService,
private entityService: EntityService, private entityService: EntityService,
private fb: UntypedFormBuilder) { private fb: UntypedFormBuilder) {
super(store); super(store);
@ -118,7 +124,7 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
for (const propName of Object.keys(changes)) { for (const propName of Object.keys(changes)) {
const change = changes[propName]; const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) { if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'targetDeviceAliasId' || propName === 'keyType') { if (propName === 'targetDevice' || propName === 'keyType' || propName === 'attributeScope') {
this.clearKeysCache(); this.clearKeysCache();
} }
} }
@ -186,9 +192,9 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
private getKeys() { private getKeys() {
if (this.keysFetchObservable$ === null) { if (this.keysFetchObservable$ === null) {
let fetchObservable: Observable<Array<DataKey>>; let fetchObservable: Observable<Array<DataKey>>;
if (this.targetDeviceAliasId) { if (targetDeviceValid(this.targetDevice)) {
const dataKeyTypes = [this.keyType]; const dataKeyTypes = [this.keyType];
fetchObservable = this.fetchEntityKeys(this.targetDeviceAliasId, dataKeyTypes); fetchObservable = this.fetchEntityKeys(this.targetDevice, dataKeyTypes);
} else { } else {
fetchObservable = of([]); fetchObservable = of([]);
} }
@ -201,17 +207,25 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
return this.keysFetchObservable$; return this.keysFetchObservable$;
} }
private fetchEntityKeys(entityAliasId: string, dataKeyTypes: Array<DataKeyType>): Observable<Array<DataKey>> { private fetchEntityKeys(targetDevice: TargetDevice, dataKeyTypes: Array<DataKeyType>): Observable<Array<DataKey>> {
return this.aliasController.getAliasInfo(entityAliasId).pipe( let entityFilter$: Observable<EntityFilter>;
mergeMap((aliasInfo) => { if (targetDevice.type === TargetDeviceType.device) {
return this.entityService.getEntityKeysByEntityFilter( entityFilter$ = of(singleEntityFilterFromDeviceId(targetDevice.deviceId));
aliasInfo.entityFilter, } else {
entityFilter$ = this.aliasController.getAliasInfo(targetDevice.entityAliasId).pipe(
map(aliasInfo => aliasInfo.entityFilter)
);
}
return entityFilter$.pipe(
mergeMap((entityFilter) =>
this.entityService.getEntityKeysByEntityFilterAndScope(
entityFilter,
dataKeyTypes, [EntityType.DEVICE], dataKeyTypes, [EntityType.DEVICE],
this.attributeScope,
{ignoreLoading: true, ignoreErrors: true} {ignoreLoading: true, ignoreErrors: true}
).pipe( ).pipe(
catchError(() => of([])) catchError(() => of([]))
); )),
}),
catchError(() => of([] as Array<DataKey>)) catchError(() => of([] as Array<DataKey>))
); );
} }

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html

@ -52,7 +52,7 @@
<tb-device-key-autocomplete fxFlex <tb-device-key-autocomplete fxFlex
[fxShow]="!ledIndicatorWidgetSettingsForm.get('performCheckStatus').value" [fxShow]="!ledIndicatorWidgetSettingsForm.get('performCheckStatus').value"
[aliasController]="aliasController" [aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId" [targetDevice]="targetDevice"
[keyType]="ledIndicatorWidgetSettingsForm.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries" [keyType]="ledIndicatorWidgetSettingsForm.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries"
attributeLabel="widgets.rpc.led-status-value-attribute" attributeLabel="widgets.rpc.led-status-value-attribute"
timeseriesLabel="widgets.rpc.led-status-value-timeseries" timeseriesLabel="widgets.rpc.led-status-value-timeseries"

10
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.ts

@ -15,7 +15,7 @@
/// ///
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -31,12 +31,8 @@ export class LedIndicatorWidgetSettingsComponent extends WidgetSettingsComponent
functionScopeVariables = this.widgetService.getWidgetScopeVariables(); functionScopeVariables = this.widgetService.getWidgetScopeVariables();
get targetDeviceAliasId(): string { get targetDevice(): TargetDevice {
const aliasIds = this.widget?.config?.targetDeviceAliasIds; return this.widget?.config?.targetDevice;
if (aliasIds && aliasIds.length) {
return aliasIds[0];
}
return null;
} }
dataKeyType = DataKeyType; dataKeyType = DataKeyType;

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html

@ -24,7 +24,7 @@
</mat-form-field> </mat-form-field>
</fieldset> </fieldset>
<tb-switch-rpc-settings [aliasController]="aliasController" <tb-switch-rpc-settings [aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId" [targetDevice]="targetDevice"
formControlName="switchRpcSettings"> formControlName="switchRpcSettings">
</tb-switch-rpc-settings> </tb-switch-rpc-settings>
</section> </section>

10
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts

@ -15,7 +15,7 @@
/// ///
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -36,12 +36,8 @@ export class RoundSwitchWidgetSettingsComponent extends WidgetSettingsComponent
super(store); super(store);
} }
get targetDeviceAliasId(): string { get targetDevice(): TargetDevice {
const aliasIds = this.widget?.config?.targetDeviceAliasIds; return this.widget?.config?.targetDevice;
if (aliasIds && aliasIds.length) {
return aliasIds[0];
}
return null;
} }
protected settingsForm(): UntypedFormGroup { protected settingsForm(): UntypedFormGroup {

198
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html

@ -0,0 +1,198 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<ng-container [formGroup]="singleSwitchWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.single-switch.behavior</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.initial-state-hint' | translate}}" translate>widgets.rpc-state.initial-state</div>
<tb-rpc-initial-state-settings fxFlex
[stateValueType]="valueType.BOOLEAN"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
formControlName="initialState"></tb-rpc-initial-state-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.turn-on-hint' | translate}}" translate>widgets.rpc-state.turn-on</div>
<tb-rpc-update-state-settings fxFlex
panelTitle="widgets.rpc-state.turn-on"
[stateValueType]="valueType.BOOLEAN"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
formControlName="onUpdateState"></tb-rpc-update-state-settings>
</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.turn-off-hint' | translate}}" translate>widgets.rpc-state.turn-off</div>
<tb-rpc-update-state-settings fxFlex
panelTitle="widgets.rpc-state.turn-off"
[stateValueType]="valueType.BOOLEAN"
[aliasController]="aliasController"
[targetDevice]="targetDevice"
formControlName="offUpdateState"></tb-rpc-update-state-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-style</div>
<tb-image-cards-select rowHeight="2:1"
[cols]="{columns: 3,
breakpoints: {
'lt-sm': 1,
'lt-md': 2
}}"
label="{{ 'widgets.single-switch.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of singleSwitchLayouts"
[value]="layout"
[image]="singleSwitchLayoutImageMap.get(layout)">
{{ singleSwitchLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.single-switch.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.single-switch.label' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="labelFont"
[previewText]="singleSwitchWidgetSettingsForm.get('label').value">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="labelColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.single-switch.icon' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
[color]="singleSwitchWidgetSettingsForm.get('iconColor').value?.color"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
colorClearButton
formControlName="iconColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.single-switch.switch</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.single-switch.switch-color' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutAlign.lt-sm="space-between center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.on</div>
<tb-color-input asBoxInput
formControlName="switchColorOn">
</tb-color-input>
</div>
<mat-divider vertical fxHide.lt-sm></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.off</div>
<tb-color-input asBoxInput
formControlName="switchColorOff">
</tb-color-input>
</div>
<mat-divider vertical fxHide.lt-sm></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.disabled</div>
<tb-color-input asBoxInput
formControlName="switchColorDisabled">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.single-switch.tumbler-color' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutAlign.lt-sm="space-between center" fxLayoutGap="12px">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.on</div>
<tb-color-input asBoxInput
formControlName="tumblerColorOn">
</tb-color-input>
</div>
<mat-divider vertical fxHide.lt-sm></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.off</div>
<tb-color-input asBoxInput
formControlName="tumblerColorOff">
</tb-color-input>
</div>
<mat-divider vertical fxHide.lt-sm></mat-divider>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div translate>widgets.single-switch.disabled</div>
<tb-color-input asBoxInput
formControlName="tumblerColorDisabled">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showOnLabel">
{{ 'widgets.single-switch.on-label' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="onLabel" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="onLabelFont"
[previewText]="singleSwitchWidgetSettingsForm.get('onLabel').value">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="onLabelColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showOffLabel">
{{ 'widgets.single-switch.off-label' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="offLabel" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="offLabelFont"
[previewText]="singleSwitchWidgetSettingsForm.get('offLabel').value">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="offLabelColor">
</tb-color-input>
</div>
</div>
</div>
</ng-container>

157
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts

@ -0,0 +1,157 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component } from '@angular/core';
import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
singleSwitchDefaultSettings, singleSwitchLayoutImages,
singleSwitchLayouts,
singleSwitchLayoutTranslations
} from '@home/components/widget/lib/rpc/single-switch-widget.models';
import { ValueType } from '@shared/models/constants';
@Component({
selector: 'tb-single-switch-widget-settings',
templateUrl: './single-switch-widget-settings.component.html',
styleUrls: ['./../widget-settings.scss']
})
export class SingleSwitchWidgetSettingsComponent extends WidgetSettingsComponent {
get targetDevice(): TargetDevice {
return this.widget?.config?.targetDevice;
}
singleSwitchLayouts = singleSwitchLayouts;
singleSwitchLayoutTranslationMap = singleSwitchLayoutTranslations;
singleSwitchLayoutImageMap = singleSwitchLayoutImages;
valueType = ValueType;
singleSwitchWidgetSettingsForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.singleSwitchWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return {...singleSwitchDefaultSettings};
}
protected onSettingsSet(settings: WidgetSettings) {
this.singleSwitchWidgetSettingsForm = this.fb.group({
initialState: [settings.initialState, []],
onUpdateState: [settings.onUpdateState, []],
offUpdateState: [settings.offUpdateState, []],
layout: [settings.layout, []],
autoScale: [settings.autoScale, []],
showLabel: [settings.showLabel, []],
label: [settings.label, []],
labelFont: [settings.labelFont, []],
labelColor: [settings.labelColor, []],
showIcon: [settings.showIcon, []],
iconSize: [settings.iconSize, [Validators.min(0)]],
iconSizeUnit: [settings.iconSizeUnit, []],
icon: [settings.icon, []],
iconColor: [settings.iconColor, []],
switchColorOn: [settings.switchColorOn, []],
switchColorOff: [settings.switchColorOff, []],
switchColorDisabled: [settings.switchColorDisabled, []],
tumblerColorOn: [settings.tumblerColorOn, []],
tumblerColorOff: [settings.tumblerColorOff, []],
tumblerColorDisabled: [settings.tumblerColorDisabled, []],
showOnLabel: [settings.showOnLabel, []],
onLabel: [settings.onLabel, []],
onLabelFont: [settings.onLabelFont, []],
onLabelColor: [settings.onLabelColor, []],
showOffLabel: [settings.showOffLabel, []],
offLabel: [settings.offLabel, []],
offLabelFont: [settings.offLabelFont, []],
offLabelColor: [settings.offLabelColor, []],
background: [settings.background, []]
});
}
protected validatorTriggers(): string[] {
return ['showLabel', 'showIcon', 'showOnLabel', 'showOffLabel'];
}
protected updateValidators(emitEvent: boolean): void {
const showLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showLabel').value;
const showIcon: boolean = this.singleSwitchWidgetSettingsForm.get('showIcon').value;
const showOnLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showOnLabel').value;
const showOffLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showOffLabel').value;
if (showLabel) {
this.singleSwitchWidgetSettingsForm.get('label').enable();
this.singleSwitchWidgetSettingsForm.get('labelFont').enable();
this.singleSwitchWidgetSettingsForm.get('labelColor').enable();
} else {
this.singleSwitchWidgetSettingsForm.get('label').disable();
this.singleSwitchWidgetSettingsForm.get('labelFont').disable();
this.singleSwitchWidgetSettingsForm.get('labelColor').disable();
}
if (showIcon) {
this.singleSwitchWidgetSettingsForm.get('iconSize').enable();
this.singleSwitchWidgetSettingsForm.get('iconSizeUnit').enable();
this.singleSwitchWidgetSettingsForm.get('icon').enable();
this.singleSwitchWidgetSettingsForm.get('iconColor').enable();
} else {
this.singleSwitchWidgetSettingsForm.get('iconSize').disable();
this.singleSwitchWidgetSettingsForm.get('iconSizeUnit').disable();
this.singleSwitchWidgetSettingsForm.get('icon').disable();
this.singleSwitchWidgetSettingsForm.get('iconColor').disable();
}
if (showOnLabel) {
this.singleSwitchWidgetSettingsForm.get('onLabel').enable();
this.singleSwitchWidgetSettingsForm.get('onLabelFont').enable();
this.singleSwitchWidgetSettingsForm.get('onLabelColor').enable();
} else {
this.singleSwitchWidgetSettingsForm.get('onLabel').disable();
this.singleSwitchWidgetSettingsForm.get('onLabelFont').disable();
this.singleSwitchWidgetSettingsForm.get('onLabelColor').disable();
}
if (showOffLabel) {
this.singleSwitchWidgetSettingsForm.get('offLabel').enable();
this.singleSwitchWidgetSettingsForm.get('offLabelFont').enable();
this.singleSwitchWidgetSettingsForm.get('offLabelColor').enable();
} else {
this.singleSwitchWidgetSettingsForm.get('offLabel').disable();
this.singleSwitchWidgetSettingsForm.get('offLabelFont').disable();
this.singleSwitchWidgetSettingsForm.get('offLabelColor').disable();
}
}
protected readonly ValueType = ValueType;
}

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html

@ -51,7 +51,7 @@
</section> </section>
</fieldset> </fieldset>
<tb-switch-rpc-settings [aliasController]="aliasController" <tb-switch-rpc-settings [aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId" [targetDevice]="targetDevice"
formControlName="switchRpcSettings"> formControlName="switchRpcSettings">
</tb-switch-rpc-settings> </tb-switch-rpc-settings>
</section> </section>

10
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.ts

@ -15,7 +15,7 @@
/// ///
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -36,12 +36,8 @@ export class SlideToggleWidgetSettingsComponent extends WidgetSettingsComponent
super(store); super(store);
} }
get targetDeviceAliasId(): string { get targetDevice(): TargetDevice {
const aliasIds = this.widget?.config?.targetDeviceAliasIds; return this.widget?.config?.targetDevice;
if (aliasIds && aliasIds.length) {
return aliasIds[0];
}
return null;
} }
protected settingsForm(): UntypedFormGroup { protected settingsForm(): UntypedFormGroup {

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html

@ -27,7 +27,7 @@
</mat-slide-toggle> </mat-slide-toggle>
</fieldset> </fieldset>
<tb-switch-rpc-settings [aliasController]="aliasController" <tb-switch-rpc-settings [aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId" [targetDevice]="targetDevice"
formControlName="switchRpcSettings"> formControlName="switchRpcSettings">
</tb-switch-rpc-settings> </tb-switch-rpc-settings>
</section> </section>

10
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts

@ -15,7 +15,7 @@
/// ///
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -36,12 +36,8 @@ export class SwitchControlWidgetSettingsComponent extends WidgetSettingsComponen
super(store); super(store);
} }
get targetDeviceAliasId(): string { get targetDevice(): TargetDevice {
const aliasIds = this.widget?.config?.targetDeviceAliasIds; return this.widget?.config?.targetDevice;
if (aliasIds && aliasIds.length) {
return aliasIds[0];
}
return null;
} }
protected settingsForm(): UntypedFormGroup { protected settingsForm(): UntypedFormGroup {

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html

@ -44,7 +44,7 @@
[fxShow]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' || [fxShow]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' ||
switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'timeseries'" switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'timeseries'"
[aliasController]="aliasController" [aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId" [targetDevice]="targetDevice"
[keyType]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries" [keyType]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries"
required required
formControlName="valueKey"> formControlName="valueKey">

16
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts

@ -17,11 +17,11 @@
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
import { import {
ControlValueAccessor, ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormBuilder, UntypedFormBuilder,
UntypedFormControl, UntypedFormControl,
UntypedFormGroup, UntypedFormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
Validator, Validator,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
@ -33,6 +33,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { WidgetService } from '@core/http/widget.service'; import { WidgetService } from '@core/http/widget.service';
import { IAliasController } from '@core/api/widget-api.models'; import { IAliasController } from '@core/api/widget-api.models';
import { EntityService } from '@core/http/entity.service'; import { EntityService } from '@core/http/entity.service';
import { TargetDevice } from '@shared/models/widget.models';
export declare type RpcRetrieveValueMethod = 'none' | 'rpc' | 'attribute' | 'timeseries'; export declare type RpcRetrieveValueMethod = 'none' | 'rpc' | 'attribute' | 'timeseries';
@ -49,8 +50,7 @@ export interface SwitchRpcSettings {
persistentPollingInterval: number; persistentPollingInterval: number;
} }
export function switchRpcDefaultSettings(): SwitchRpcSettings { export const switchRpcDefaultSettings = (): SwitchRpcSettings => ({
return {
initialValue: false, initialValue: false,
retrieveValueMethod: 'rpc', retrieveValueMethod: 'rpc',
valueKey: 'value', valueKey: 'value',
@ -61,8 +61,7 @@ export function switchRpcDefaultSettings(): SwitchRpcSettings {
requestTimeout: 500, requestTimeout: 500,
requestPersistent: false, requestPersistent: false,
persistentPollingInterval: 5000 persistentPollingInterval: 5000
}; });
}
@Component({ @Component({
selector: 'tb-switch-rpc-settings', selector: 'tb-switch-rpc-settings',
@ -92,7 +91,7 @@ export class SwitchRpcSettingsComponent extends PageComponent implements OnInit,
aliasController: IAliasController; aliasController: IAliasController;
@Input() @Input()
targetDeviceAliasId: string; targetDevice: TargetDevice;
dataKeyType = DataKeyType; dataKeyType = DataKeyType;
@ -185,8 +184,7 @@ export class SwitchRpcSettingsComponent extends PageComponent implements OnInit,
} }
private updateModel() { private updateModel() {
const value: SwitchRpcSettings = this.switchRpcSettingsFormGroup.value; this.modelValue = this.switchRpcSettingsFormGroup.value;
this.modelValue = value;
this.propagateChange(this.modelValue); this.propagateChange(this.modelValue);
} }

17
ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts

@ -123,9 +123,6 @@ import {
import { import {
SendRpcWidgetSettingsComponent SendRpcWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/send-rpc-widget-settings.component'; } from '@home/components/widget/lib/settings/control/send-rpc-widget-settings.component';
import {
DeviceKeyAutocompleteComponent
} from '@home/components/widget/lib/settings/control/device-key-autocomplete.component';
import { import {
LedIndicatorWidgetSettingsComponent LedIndicatorWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/led-indicator-widget-settings.component'; } from '@home/components/widget/lib/settings/control/led-indicator-widget-settings.component';
@ -311,6 +308,9 @@ import {
import { import {
BarChartWithLabelsWidgetSettingsComponent BarChartWithLabelsWidgetSettingsComponent
} from '@home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component'; } from '@home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component';
import {
SingleSwitchWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/single-switch-widget-settings.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -348,7 +348,6 @@ import {
FlotPieKeySettingsComponent, FlotPieKeySettingsComponent,
ChartWidgetSettingsComponent, ChartWidgetSettingsComponent,
DoughnutChartWidgetSettingsComponent, DoughnutChartWidgetSettingsComponent,
DeviceKeyAutocompleteComponent,
SwitchRpcSettingsComponent, SwitchRpcSettingsComponent,
RoundSwitchWidgetSettingsComponent, RoundSwitchWidgetSettingsComponent,
SwitchControlWidgetSettingsComponent, SwitchControlWidgetSettingsComponent,
@ -424,7 +423,8 @@ import {
LiquidLevelCardWidgetSettingsComponent, LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent, DoughnutWidgetSettingsComponent,
RangeChartWidgetSettingsComponent, RangeChartWidgetSettingsComponent,
BarChartWithLabelsWidgetSettingsComponent BarChartWithLabelsWidgetSettingsComponent,
SingleSwitchWidgetSettingsComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -467,7 +467,6 @@ import {
FlotPieKeySettingsComponent, FlotPieKeySettingsComponent,
ChartWidgetSettingsComponent, ChartWidgetSettingsComponent,
DoughnutChartWidgetSettingsComponent, DoughnutChartWidgetSettingsComponent,
DeviceKeyAutocompleteComponent,
SwitchRpcSettingsComponent, SwitchRpcSettingsComponent,
RoundSwitchWidgetSettingsComponent, RoundSwitchWidgetSettingsComponent,
SwitchControlWidgetSettingsComponent, SwitchControlWidgetSettingsComponent,
@ -543,7 +542,8 @@ import {
LiquidLevelCardWidgetSettingsComponent, LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent, DoughnutWidgetSettingsComponent,
RangeChartWidgetSettingsComponent, RangeChartWidgetSettingsComponent,
BarChartWithLabelsWidgetSettingsComponent BarChartWithLabelsWidgetSettingsComponent,
SingleSwitchWidgetSettingsComponent
] ]
}) })
export class WidgetSettingsModule { export class WidgetSettingsModule {
@ -628,5 +628,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type<IWidgetSettingsCo
'tb-liquid-level-card-widget-settings': LiquidLevelCardWidgetSettingsComponent, 'tb-liquid-level-card-widget-settings': LiquidLevelCardWidgetSettingsComponent,
'tb-doughnut-widget-settings': DoughnutWidgetSettingsComponent, 'tb-doughnut-widget-settings': DoughnutWidgetSettingsComponent,
'tb-range-chart-widget-settings': RangeChartWidgetSettingsComponent, 'tb-range-chart-widget-settings': RangeChartWidgetSettingsComponent,
'tb-bar-chart-with-labels-widget-settings': BarChartWithLabelsWidgetSettingsComponent 'tb-bar-chart-with-labels-widget-settings': BarChartWithLabelsWidgetSettingsComponent,
'tb-single-switch-widget-settings': SingleSwitchWidgetSettingsComponent
}; };

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

@ -574,6 +574,9 @@ export class WidgetComponentService {
if (!isFunction(result.typeParameters.defaultLatestDataKeysFunction)) { if (!isFunction(result.typeParameters.defaultLatestDataKeysFunction)) {
result.typeParameters.defaultLatestDataKeysFunction = null; result.typeParameters.defaultLatestDataKeysFunction = null;
} }
if (isUndefined(result.typeParameters.displayRpcMessageToast)) {
result.typeParameters.displayRpcMessageToast = true;
}
if (isFunction(widgetTypeInstance.actionSources)) { if (isFunction(widgetTypeInstance.actionSources)) {
result.actionSources = widgetTypeInstance.actionSources(); result.actionSources = widgetTypeInstance.actionSources();
} else { } else {

7
ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts

@ -70,6 +70,7 @@ import { RangeChartWidgetComponent } from '@home/components/widget/lib/chart/ran
import { import {
BarChartWithLabelsWidgetComponent BarChartWithLabelsWidgetComponent
} from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.component'; } from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.component';
import { SingleSwitchWidgetComponent } from '@home/components/widget/lib/rpc/single-switch-widget.component';
@NgModule({ @NgModule({
declarations: declarations:
@ -112,7 +113,8 @@ import {
LiquidLevelWidgetComponent, LiquidLevelWidgetComponent,
DoughnutWidgetComponent, DoughnutWidgetComponent,
RangeChartWidgetComponent, RangeChartWidgetComponent,
BarChartWithLabelsWidgetComponent BarChartWithLabelsWidgetComponent,
SingleSwitchWidgetComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -159,7 +161,8 @@ import {
LiquidLevelWidgetComponent, LiquidLevelWidgetComponent,
DoughnutWidgetComponent, DoughnutWidgetComponent,
RangeChartWidgetComponent, RangeChartWidgetComponent,
BarChartWithLabelsWidgetComponent BarChartWithLabelsWidgetComponent,
SingleSwitchWidgetComponent
], ],
providers: [ providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule }

22
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -81,7 +81,7 @@
</div> </div>
</div> </div>
<mat-expansion-panel class="tb-settings"> <mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header expandedHeight="48px"> <mat-expansion-panel-header>
<mat-panel-description fxLayoutAlign="start" translate> <mat-panel-description fxLayoutAlign="start" translate>
widget-config.advanced-title-style widget-config.advanced-title-style
</mat-panel-description> </mat-panel-description>
@ -241,21 +241,13 @@
formControlName="datasources"> formControlName="datasources">
</tb-datasources> </tb-datasources>
</div> </div>
<div class="tb-form-panel" *ngIf="widgetType === widgetTypes.rpc && <ng-container *ngIf="widgetType === widgetTypes.rpc &&
modelValue?.isDataEnabled"> modelValue?.isDataEnabled" [formGroup]="targetDeviceSettings">
<div class="tb-form-panel-title" translate>widget-config.target-device</div> <tb-target-device formControlName="targetDevice">
<div [formGroup]="targetDeviceSettings"> </tb-target-device>
<tb-entity-alias-select fxFlex </ng-container>
[tbRequired]="!widgetEditMode"
[aliasController]="aliasController"
[allowedEntityTypes]="[entityTypes.DEVICE]"
[callbacks]="widgetConfigCallbacks"
formControlName="targetDeviceAliasId">
</tb-entity-alias-select>
</div>
</div>
<div *ngIf="widgetType === widgetTypes.alarm && modelValue?.isDataEnabled" <div *ngIf="widgetType === widgetTypes.alarm && modelValue?.isDataEnabled"
[formGroup]="dataSettings" class="tb-form-panel" > [formGroup]="dataSettings" class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.alarm-source</div> <div class="tb-form-panel-title" translate>widget-config.alarm-source</div>
<tb-datasource <tb-datasource
formControlName="alarmSource"> formControlName="alarmSource">

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

@ -36,6 +36,8 @@ import {
GroupInfo, GroupInfo,
JsonSchema, JsonSchema,
JsonSettingsSchema, JsonSettingsSchema,
TargetDevice,
TargetDeviceType, targetDeviceValid,
Widget, Widget,
WidgetConfigMode, WidgetConfigMode,
widgetType widgetType
@ -368,9 +370,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
this.widgetType !== widgetType.static) { this.widgetType !== widgetType.static) {
this.dataSettings.addControl('datasources', this.fb.control(null)); this.dataSettings.addControl('datasources', this.fb.control(null));
} else if (this.widgetType === widgetType.rpc) { } else if (this.widgetType === widgetType.rpc) {
this.targetDeviceSettings.addControl('targetDeviceAliasId', this.targetDeviceSettings.addControl('targetDevice',
this.fb.control(null, this.fb.control(null, []));
this.widgetEditMode ? [] : [Validators.required]));
} else if (this.widgetType === widgetType.alarm) { } else if (this.widgetType === widgetType.alarm) {
this.dataSettings.addControl('alarmSource', this.fb.control(null)); this.dataSettings.addControl('alarmSource', this.fb.control(null));
} }
@ -534,20 +535,9 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
this.dataSettings.patchValue({ datasources: config.datasources}, this.dataSettings.patchValue({ datasources: config.datasources},
{emitEvent: false}); {emitEvent: false});
} else if (this.widgetType === widgetType.rpc) { } else if (this.widgetType === widgetType.rpc) {
let targetDeviceAliasId: string; const targetDevice: TargetDevice = config.targetDevice;
if (config.targetDeviceAliasIds && config.targetDeviceAliasIds.length > 0) {
const aliasId = config.targetDeviceAliasIds[0];
const entityAliases = this.aliasController.getEntityAliases();
if (entityAliases[aliasId]) {
targetDeviceAliasId = entityAliases[aliasId].id;
} else {
targetDeviceAliasId = null;
}
} else {
targetDeviceAliasId = null;
}
this.targetDeviceSettings.patchValue({ this.targetDeviceSettings.patchValue({
targetDeviceAliasId targetDevice
}, {emitEvent: false}); }, {emitEvent: false});
} else if (this.widgetType === widgetType.alarm) { } else if (this.widgetType === widgetType.alarm) {
this.dataSettings.patchValue( this.dataSettings.patchValue(
@ -650,12 +640,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
private updateTargetDeviceSettings() { private updateTargetDeviceSettings() {
if (this.modelValue) { if (this.modelValue) {
if (this.modelValue.config) { if (this.modelValue.config) {
const targetDeviceAliasId: string = this.targetDeviceSettings.get('targetDeviceAliasId').value; this.modelValue.config.targetDevice = this.targetDeviceSettings.get('targetDevice').value;
if (targetDeviceAliasId) {
this.modelValue.config.targetDeviceAliasIds = [targetDeviceAliasId];
} else {
this.modelValue.config.targetDeviceAliasIds = [];
}
} }
this.propagateChange(this.modelValue); this.propagateChange(this.modelValue);
} }
@ -944,9 +929,9 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
if (this.modelValue) { if (this.modelValue) {
const config = this.modelValue.config; const config = this.modelValue.config;
if (this.widgetType === widgetType.rpc && this.modelValue.isDataEnabled) { if (this.widgetType === widgetType.rpc && this.modelValue.isDataEnabled) {
if (!this.widgetEditMode && (!config.targetDeviceAliasIds || !config.targetDeviceAliasIds.length)) { if (!this.widgetEditMode && !targetDeviceValid(config.targetDevice)) {
return { return {
targetDeviceAliasIds: { targetDevice: {
valid: false valid: false
} }
}; };

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

@ -19,7 +19,6 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ComponentFactoryResolver,
ComponentRef, ComponentRef,
ElementRef, ElementRef,
Inject, Inject,
@ -110,12 +109,12 @@ import { MatDialog } from '@angular/material/dialog';
import { ComponentType } from '@angular/cdk/portal'; import { ComponentType } from '@angular/cdk/portal';
import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token'; import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token';
import { MobileService } from '@core/services/mobile.service'; import { MobileService } from '@core/services/mobile.service';
import { DialogService } from '@core/services/dialog.service';
import { PopoverPlacement } from '@shared/components/popover.models'; import { PopoverPlacement } from '@shared/components/popover.models';
import { TbPopoverService } from '@shared/components/popover.service'; import { TbPopoverService } from '@shared/components/popover.service';
import { DASHBOARD_PAGE_COMPONENT_TOKEN } from '@home/components/tokens'; import { DASHBOARD_PAGE_COMPONENT_TOKEN } from '@home/components/tokens';
import { MODULES_MAP } from '@shared/models/constants'; import { MODULES_MAP } from '@shared/models/constants';
import { IModulesMap } from '@modules/common/modules-map.models'; import { IModulesMap } from '@modules/common/modules-map.models';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
@Component({ @Component({
selector: 'tb-widget', selector: 'tb-widget',
@ -179,7 +178,6 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private widgetComponentService: WidgetComponentService, private widgetComponentService: WidgetComponentService,
private componentFactoryResolver: ComponentFactoryResolver,
private elementRef: ElementRef, private elementRef: ElementRef,
private injector: Injector, private injector: Injector,
private dialog: MatDialog, private dialog: MatDialog,
@ -198,8 +196,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
private alarmDataService: AlarmDataService, private alarmDataService: AlarmDataService,
private translate: TranslateService, private translate: TranslateService,
private utils: UtilsService, private utils: UtilsService,
private dashboardUtils: DashboardUtilsService,
private mobileService: MobileService, private mobileService: MobileService,
private dialogs: DialogService,
private raf: RafService, private raf: RafService,
private ngZone: NgZone, private ngZone: NgZone,
private cd: ChangeDetectorRef) { private cd: ChangeDetectorRef) {
@ -298,6 +296,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.subscriptionContext.entityDataService = this.entityDataService; this.subscriptionContext.entityDataService = this.entityDataService;
this.subscriptionContext.alarmDataService = this.alarmDataService; this.subscriptionContext.alarmDataService = this.alarmDataService;
this.subscriptionContext.utils = this.utils; this.subscriptionContext.utils = this.utils;
this.subscriptionContext.dashboardUtils = this.dashboardUtils;
this.subscriptionContext.raf = this.raf; this.subscriptionContext.raf = this.raf;
this.subscriptionContext.widgetUtils = this.widgetContext.utils; this.subscriptionContext.widgetUtils = this.widgetContext.utils;
this.subscriptionContext.getServerTimeDiff = this.dashboardService.getServerTimeDiff.bind(this.dashboardService); this.subscriptionContext.getServerTimeDiff = this.dashboardService.getServerTimeDiff.bind(this.dashboardService);
@ -959,7 +958,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.loadingData = false; this.loadingData = false;
options = { options = {
type: this.widget.type, type: this.widget.type,
targetDeviceAliasIds: this.widget.config.targetDeviceAliasIds targetDevice: this.widget.config.targetDevice
}; };
options.callbacks = { options.callbacks = {
rpcStateChanged: (subscription) => { rpcStateChanged: (subscription) => {
@ -974,7 +973,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest;
this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText; this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText;
this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection; this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection;
this.clearMessage(); if (this.typeParameters.displayRpcMessageToast) {
this.clearMessage();
}
this.detectChanges(); this.detectChanges();
} }
}, },
@ -983,7 +984,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest;
this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText; this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText;
this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection; this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection;
if (subscription.rpcErrorText) { if (subscription.rpcErrorText && this.typeParameters.displayRpcMessageToast) {
this.displayMessage('error', subscription.rpcErrorText); this.displayMessage('error', subscription.rpcErrorText);
} }
this.detectChanges(); this.detectChanges();
@ -993,7 +994,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
if (this.dynamicWidgetComponent) { if (this.dynamicWidgetComponent) {
this.dynamicWidgetComponent.rpcErrorText = null; this.dynamicWidgetComponent.rpcErrorText = null;
this.dynamicWidgetComponent.rpcRejection = null; this.dynamicWidgetComponent.rpcRejection = null;
this.clearMessage(); if (this.typeParameters.displayRpcMessageToast) {
this.clearMessage();
}
this.detectChanges(); this.detectChanges();
} }
} }

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

@ -53,7 +53,13 @@ import { RafService } from '@core/services/raf.service';
import { WidgetTypeId } from '@shared/models/id/widget-type-id'; import { WidgetTypeId } from '@shared/models/id/widget-type-id';
import { TenantId } from '@shared/models/id/tenant-id'; import { TenantId } from '@shared/models/id/tenant-id';
import { WidgetLayout } from '@shared/models/dashboard.models'; import { WidgetLayout } from '@shared/models/dashboard.models';
import { createLabelFromDatasource, formatValue, hasDatasourceLabelsVariables, isDefined } from '@core/utils'; import {
createLabelFromDatasource,
createLabelFromSubscriptionEntityInfo,
formatValue,
hasDatasourceLabelsVariables,
isDefined
} from '@core/utils';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { import {
@ -116,7 +122,7 @@ export interface WidgetAction extends IWidgetAction {
} }
export interface IDashboardWidget { export interface IDashboardWidget {
updateWidgetParams(); updateWidgetParams(): void;
} }
export class WidgetContext { export class WidgetContext {
@ -482,9 +488,14 @@ export class LabelVariablePattern {
update() { update() {
let label = this.pattern; let label = this.pattern;
const datasource = this.ctx.defaultSubscription?.firstDatasource; if (this.hasVariables) {
if (this.hasVariables && datasource) { if (this.ctx.defaultSubscription?.type === widgetType.rpc) {
label = createLabelFromDatasource(datasource, label); const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo();
label = createLabelFromSubscriptionEntityInfo(entityInfo, label);
} else {
const datasource = this.ctx.defaultSubscription?.firstDatasource;
label = createLabelFromDatasource(datasource, label);
}
} }
if (this.labelSubject.value !== label) { if (this.labelSubject.value !== label) {
this.labelSubject.next(label); this.labelSubject.next(label);
@ -503,7 +514,7 @@ export interface IDynamicWidgetComponent {
executingRpcRequest: boolean; executingRpcRequest: boolean;
rpcEnabled: boolean; rpcEnabled: boolean;
rpcErrorText: string; rpcErrorText: string;
rpcRejection: HttpErrorResponse; rpcRejection: HttpErrorResponse | Error;
raf: RafService; raf: RafService;
[key: string]: any; [key: string]: any;
} }

2
ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div class="mat-content tb-rulechain" fxFlex tb-fullscreen [fullscreen]="isFullscreen" tb-hotkeys [hotkeys]="hotKeys" <div class="mat-content tb-rulechain" fxFlex tb-fullscreen [fullscreen]="isFullscreen" [tb-hotkeys]="hotKeys"
[cheatSheet]="cheatSheetComponent" fxLayout="column"> [cheatSheet]="cheatSheetComponent" fxLayout="column">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet> <tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
<section class="tb-rulechain-container" fxFlex fxLayout="column"> <section class="tb-rulechain-container" fxFlex fxLayout="column">

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

@ -16,7 +16,7 @@
--> -->
<div fxFlex fxLayout="column"> <div fxFlex fxLayout="column">
<div fxFlex fxLayout="column" tb-fullscreen [fullscreen]="fullscreen" tb-hotkeys [hotkeys]="hotKeys" [cheatSheet]="cheatSheetComponent"> <div fxFlex fxLayout="column" tb-fullscreen [fullscreen]="fullscreen" [tb-hotkeys]="hotKeys" [cheatSheet]="cheatSheetComponent">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet> <tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
<mat-toolbar class="mat-elevation-z1 tb-edit-toolbar mat-hue-3" fxLayoutGap="16px" fxLayoutGap.lt-xl="8px"> <mat-toolbar class="mat-elevation-z1 tb-edit-toolbar mat-hue-3" fxLayoutGap="16px" fxLayoutGap.lt-xl="8px">
<mat-form-field class="tb-widget-title tb-appearance-transparent"> <mat-form-field class="tb-widget-title tb-appearance-transparent">
@ -25,8 +25,8 @@
placeholder="{{ 'widget.widget-title' | translate }}"/> placeholder="{{ 'widget.widget-title' | translate }}"/>
</mat-form-field> </mat-form-field>
<mat-form-field class="tb-appearance-transparent"> <mat-form-field class="tb-appearance-transparent">
<mat-select [disabled]="isReadOnly" matInput placeholder="{{ 'widget.type' | translate }}" <mat-select [disabled]="isReadOnly" placeholder="{{ 'widget.type' | translate }}"
[(ngModel)]="widget.type" (ngModelChange)="widetTypeChanged()"> [(ngModel)]="widget.type" (ngModelChange)="widgetTypeChanged()">
<mat-option *ngFor="let type of allWidgetTypes" [value]="type"> <mat-option *ngFor="let type of allWidgetTypes" [value]="type">
{{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }} {{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }}
</mat-option> </mat-option>
@ -322,7 +322,7 @@
<mat-spinner diameter="100" mode="indeterminate" color="warn"></mat-spinner> <mat-spinner diameter="100" mode="indeterminate" color="warn"></mat-spinner>
</div> </div>
<div tb-fullscreen [fullscreen]="iFrameFullscreen" style="width: 100%; height: 100%;"> <div tb-fullscreen [fullscreen]="iFrameFullscreen" style="width: 100%; height: 100%;">
<iframe #widgetIFrame frameborder="0" height="100%" width="100%"></iframe> <iframe #widgetIFrame style="border: 0;" height="100%" width="100%"></iframe>
<div style="position: absolute; top: 10px; left: 10px; bottom: initial;"> <div style="position: absolute; top: 10px; left: 10px; bottom: initial;">
<button mat-icon-button <button mat-icon-button
class="tb-fullscreen-button-style" class="tb-fullscreen-button-style"

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

@ -29,7 +29,14 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { WidgetService } from '@core/http/widget.service'; import { WidgetService } from '@core/http/widget.service';
import { detailsToWidgetInfo, WidgetInfo } from '@home/models/widget-component.models'; import { detailsToWidgetInfo, WidgetInfo } from '@home/models/widget-component.models';
import { Widget, WidgetConfig, widgetType, WidgetTypeDetails, widgetTypesData } from '@shared/models/widget.models'; import {
TargetDeviceType,
Widget,
WidgetConfig,
widgetType,
WidgetTypeDetails,
widgetTypesData
} from '@shared/models/widget.models';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { deepClone } from '@core/utils'; import { deepClone } from '@core/utils';
import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard'; import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard';
@ -789,12 +796,12 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
this.isDirty = true; this.isDirty = true;
} }
widetTypeChanged() { widgetTypeChanged() {
const config: WidgetConfig = JSON.parse(this.widget.defaultConfig); const config: WidgetConfig = JSON.parse(this.widget.defaultConfig);
if (this.widget.type !== widgetType.rpc && if (this.widget.type !== widgetType.rpc &&
this.widget.type !== widgetType.alarm) { this.widget.type !== widgetType.alarm) {
if (config.targetDeviceAliases) { if (config.targetDevice) {
delete config.targetDeviceAliases; delete config.targetDevice;
} }
if (config.alarmSource) { if (config.alarmSource) {
delete config.alarmSource; delete config.alarmSource;
@ -819,15 +826,17 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
if (config.timewindow) { if (config.timewindow) {
delete config.timewindow; delete config.timewindow;
} }
if (!config.targetDeviceAliases) { if (!config.targetDevice) {
config.targetDeviceAliases = []; config.targetDevice = {
type: TargetDeviceType.device
};
} }
} else { // alarm } else { // alarm
if (config.datasources) { if (config.datasources) {
delete config.datasources; delete config.datasources;
} }
if (config.targetDeviceAliases) { if (config.targetDevice) {
delete config.targetDeviceAliases; delete config.targetDevice;
} }
if (!config.alarmSource) { if (!config.alarmSource) {
config.alarmSource = {}; config.alarmSource = {};

2
ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html

@ -37,7 +37,7 @@
[displayWith]="displayWidgetTypeFn"> [displayWith]="displayWidgetTypeFn">
<mat-option *ngFor="let widgetType of filteredWidgetTypes | async" [value]="widgetType"> <mat-option *ngFor="let widgetType of filteredWidgetTypes | async" [value]="widgetType">
<div class="tb-widget-type-option-container"> <div class="tb-widget-type-option-container">
<img class="tb-widget-type-option-image-preview" [src]="getPreviewImage(widgetType.image)" alt="{{ widgetType.name }}"> <img class="tb-widget-type-option-image-preview" [src]="widgetType.image | image: {preview: true, emptyUrl: '/assets/widget-preview-empty.svg'} | async" alt="{{ widgetType.name }}">
<div class="tb-widget-type-option-details"> <div class="tb-widget-type-option-details">
<div class="tb-widget-type-option-text" [innerHTML]="widgetType.name | highlight:searchText:true"></div> <div class="tb-widget-type-option-text" [innerHTML]="widgetType.name | highlight:searchText:true"></div>
<div *ngIf="widgetType.deprecated" class="tb-widget-type-option-deprecated">{{ 'widget.deprecated' | translate }}</div> <div *ngIf="widgetType.deprecated" class="tb-widget-type-option-deprecated">{{ 'widget.deprecated' | translate }}</div>

21
ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts

@ -30,15 +30,11 @@ import { PageLink } from '@shared/models/page/page-link';
import { Direction } from '@shared/models/page/sort-order'; import { Direction } from '@shared/models/page/sort-order';
import { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; import { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators';
import { emptyPageData } from '@shared/models/page/page-data'; import { emptyPageData } from '@shared/models/page/page-data';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
import { WidgetTypeInfo } from '@shared/models/widget.models'; import { WidgetTypeInfo } from '@shared/models/widget.models';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { WidgetService } from '@core/http/widget.service'; import { WidgetService } from '@core/http/widget.service';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { isDefinedAndNotNull } from '@core/utils';
@Component({ @Component({
selector: 'tb-widget-type-autocomplete', selector: 'tb-widget-type-autocomplete',
@ -90,12 +86,10 @@ export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, On
searchText = ''; searchText = '';
private propagateChange = (v: any) => { }; private propagateChange = (_v: any) => { };
constructor(private store: Store<AppState>, constructor(public translate: TranslateService,
public translate: TranslateService,
private widgetService: WidgetService, private widgetService: WidgetService,
private sanitizer: DomSanitizer,
private fb: UntypedFormBuilder) { private fb: UntypedFormBuilder) {
this.selectWidgetTypeFormGroup = this.fb.group({ this.selectWidgetTypeFormGroup = this.fb.group({
widgetType: [null] widgetType: [null]
@ -106,7 +100,7 @@ export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, On
this.propagateChange = fn; this.propagateChange = fn;
} }
registerOnTouched(fn: any): void { registerOnTouched(_fn: any): void {
} }
ngOnInit() { ngOnInit() {
@ -114,7 +108,7 @@ export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, On
.pipe( .pipe(
debounceTime(150), debounceTime(150),
tap(value => { tap(value => {
let modelValue; let modelValue: WidgetTypeInfo;
if (typeof value === 'string' || !value) { if (typeof value === 'string' || !value) {
modelValue = null; modelValue = null;
} else { } else {
@ -173,13 +167,6 @@ export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, On
return widgetType ? widgetType.name : undefined; return widgetType ? widgetType.name : undefined;
} }
getPreviewImage(imageUrl: string | null): SafeUrl | string {
if (isDefinedAndNotNull(imageUrl)) {
return this.sanitizer.bypassSecurityTrustUrl(imageUrl);
}
return '/assets/widget-preview-empty.svg';
}
fetchWidgetTypes(searchText?: string): Observable<Array<WidgetTypeInfo>> { fetchWidgetTypes(searchText?: string): Observable<Array<WidgetTypeInfo>> {
this.searchText = searchText; this.searchText = searchText;
const pageLink = new PageLink(10, 0, searchText, { const pageLink = new PageLink(10, 0, searchText, {

11
ui-ngx/src/app/shared/components/fullscreen.directive.ts

@ -24,8 +24,7 @@ import {
Output, Output,
Renderer2, Renderer2,
SecurityContext, SecurityContext,
SimpleChanges, SimpleChanges
ViewContainerRef
} from '@angular/core'; } from '@angular/core';
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentPortal } from '@angular/cdk/portal';
@ -33,6 +32,7 @@ import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; import { DomSanitizer, SafeStyle } from '@angular/platform-browser';
@Directive({ @Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: '[tb-fullscreen]' selector: '[tb-fullscreen]'
}) })
export class FullscreenDirective implements OnChanges, OnDestroy { export class FullscreenDirective implements OnChanges, OnDestroy {
@ -60,7 +60,6 @@ export class FullscreenDirective implements OnChanges, OnDestroy {
constructor(public elementRef: ElementRef, constructor(public elementRef: ElementRef,
private renderer: Renderer2, private renderer: Renderer2,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
private viewContainerRef: ViewContainerRef,
private overlay: Overlay) { private overlay: Overlay) {
} }
@ -118,10 +117,10 @@ export class FullscreenDirective implements OnChanges, OnDestroy {
this.fullscreenChanged.emit(true); this.fullscreenChanged.emit(true);
} }
private setStyle(el: any, nameAndUnit: string, value: any): void { private setStyle(_el: any, nameAndUnit: string, value: any): void {
const [name, unit] = nameAndUnit.split('.'); const [name, unit] = nameAndUnit.split('.');
let renderValue: string|null = let renderValue: string | null =
this.sanitizer.sanitize(SecurityContext.STYLE, value as{} | string); this.sanitizer.sanitize(SecurityContext.STYLE, value);
if (renderValue != null) { if (renderValue != null) {
renderValue = renderValue.toString(); renderValue = renderValue.toString();
} }

9
ui-ngx/src/app/shared/components/hotkeys.directive.ts

@ -17,14 +17,15 @@
import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';
import { Hotkey } from 'angular2-hotkeys'; import { Hotkey } from 'angular2-hotkeys';
import { MousetrapInstance } from 'mousetrap'; import { MousetrapInstance } from 'mousetrap';
import * as Mousetrap from 'mousetrap'; import Mousetrap from 'mousetrap';
import { TbCheatSheetComponent } from '@shared/components/cheatsheet.component'; import { TbCheatSheetComponent } from '@shared/components/cheatsheet.component';
@Directive({ @Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector : '[tb-hotkeys]' selector : '[tb-hotkeys]'
}) })
export class TbHotkeysDirective implements OnInit, OnDestroy { export class TbHotkeysDirective implements OnInit, OnDestroy {
@Input() hotkeys: Hotkey[] = []; @Input('tb-hotkeys') hotkeys: Hotkey[] = [];
@Input() cheatSheet: TbCheatSheetComponent; @Input() cheatSheet: TbCheatSheetComponent;
private mousetrap: MousetrapInstance; private mousetrap: MousetrapInstance;
@ -46,7 +47,7 @@ export class TbHotkeysDirective implements OnInit, OnDestroy {
if (this.cheatSheet) { if (this.cheatSheet) {
const hotkeyObj: Hotkey = new Hotkey( const hotkeyObj: Hotkey = new Hotkey(
'?', '?',
(event: KeyboardEvent) => { () => {
this.cheatSheet.toggleCheatSheet(); this.cheatSheet.toggleCheatSheet();
return false; return false;
}, },
@ -63,7 +64,7 @@ export class TbHotkeysDirective implements OnInit, OnDestroy {
this.mousetrap.bind((hotkey as Hotkey).combo, (event: KeyboardEvent, combo: string) => { this.mousetrap.bind((hotkey as Hotkey).combo, (event: KeyboardEvent, combo: string) => {
let shouldExecute = true; let shouldExecute = true;
if (event) { if (event) {
const target: HTMLElement = (event.target || event.srcElement) as HTMLElement; const target: HTMLElement = event.target as HTMLElement;
const nodeName: string = target.nodeName.toUpperCase(); const nodeName: string = target.nodeName.toUpperCase();
if ((' ' + target.className + ' ').indexOf(' mousetrap ') > -1) { if ((' ' + target.className + ' ').indexOf(' mousetrap ') > -1) {
shouldExecute = true; shouldExecute = true;

8
ui-ngx/src/app/shared/components/popover.component.scss

@ -33,6 +33,10 @@ $box-shadow-base: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0,
user-select: text; user-select: text;
max-width: 100vw; max-width: 100vw;
max-height: 100vh; max-height: 100vh;
&.strict-position {
width: 100%;
height: 100%;
}
&::after { &::after {
position: absolute; position: absolute;
@ -112,6 +116,10 @@ $box-shadow-base: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0,
overflow: auto; overflow: auto;
width: 100%; width: 100%;
height: 100%; height: 100%;
&.strict-position {
display: flex;
overflow: hidden;
}
} }
// Arrows // Arrows

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

@ -48,11 +48,15 @@ import {
} from '@angular/cdk/overlay'; } from '@angular/cdk/overlay';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { import {
convertStrictPopoverPlacement,
DEFAULT_POPOVER_POSITIONS, DEFAULT_POPOVER_POSITIONS,
getPlacementName, getPlacementName,
isStrictPopoverPlacement,
popoverMotion, popoverMotion,
PopoverPlacement, PopoverPlacement,
PropertyMapping PopoverPreferredPlacement,
PropertyMapping,
StrictPopoverPlacement
} from '@shared/components/popover.models'; } from '@shared/components/popover.models';
import { POSITION_MAP } from '@shared/models/overlay.models'; import { POSITION_MAP } from '@shared/models/overlay.models';
import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators';
@ -313,7 +317,8 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
[cdkConnectedOverlayPositions]="positions" [cdkConnectedOverlayPositions]="positions"
[cdkConnectedOverlayScrollStrategy]="scrollStrategy" [cdkConnectedOverlayScrollStrategy]="scrollStrategy"
[cdkConnectedOverlayOpen]="visible" [cdkConnectedOverlayOpen]="visible"
[cdkConnectedOverlayPush]="true" [cdkConnectedOverlayPush]="!strictPosition"
[cdkConnectedOverlayFlexibleDimensions]="strictPosition"
(overlayOutsideClick)="onClickOutside($event)" (overlayOutsideClick)="onClickOutside($event)"
(detach)="hide()" (detach)="hide()"
(positionChange)="onPositionChange($event)" (positionChange)="onPositionChange($event)"
@ -323,6 +328,7 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
<div <div
#popover #popover
class="tb-popover" class="tb-popover"
[class.strict-position]="strictPosition"
[class.tb-popover-rtl]="dir === 'rtl'" [class.tb-popover-rtl]="dir === 'rtl'"
[ngClass]="classMap" [ngClass]="classMap"
[ngStyle]="tbOverlayStyle" [ngStyle]="tbOverlayStyle"
@ -334,7 +340,8 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
<div class="tb-popover-inner" [ngStyle]="tbPopoverInnerStyle" role="tooltip"> <div class="tb-popover-inner" [ngStyle]="tbPopoverInnerStyle" role="tooltip">
<div *ngIf="tbShowCloseButton" class="tb-popover-close-button" (click)="closeButtonClick($event)">×</div> <div *ngIf="tbShowCloseButton" class="tb-popover-close-button" (click)="closeButtonClick($event)">×</div>
<div style="width: 100%; height: 100%;"> <div style="width: 100%; height: 100%;">
<div class="tb-popover-inner-content"> <div class="tb-popover-inner-content"
[class.strict-position]="strictPosition">
<ng-container *ngIf="tbContent"> <ng-container *ngIf="tbContent">
<ng-container *tbStringTemplateOutlet="tbContent; context: tbComponentContext"> <ng-container *tbStringTemplateOutlet="tbContent; context: tbComponentContext">
{{ tbContent }} {{ tbContent }}
@ -434,12 +441,23 @@ export class TbPopoverComponent<T = any> implements OnDestroy, OnInit {
protected trigger: TbPopoverTrigger = 'hover'; protected trigger: TbPopoverTrigger = 'hover';
set tbPlacement(value: PopoverPlacement | PopoverPlacement[]) { set tbPlacement(value: PopoverPreferredPlacement) {
if (typeof value === 'string') { if (typeof value === 'string') {
this.positions = [POSITION_MAP[value], ...DEFAULT_POPOVER_POSITIONS]; if (isStrictPopoverPlacement(value)) {
const placement = convertStrictPopoverPlacement(value as StrictPopoverPlacement);
this.positions = [POSITION_MAP[placement]];
this.strictPosition = true;
} else {
this.positions = [POSITION_MAP[value], ...DEFAULT_POPOVER_POSITIONS];
}
} else { } else {
const preferredPosition = value.map(placement => POSITION_MAP[placement]); if (value.length && isStrictPopoverPlacement(value[0])) {
this.positions = [...preferredPosition, ...DEFAULT_POPOVER_POSITIONS]; this.positions = value.map((val: any) => POSITION_MAP[convertStrictPopoverPlacement(val)]);
this.strictPosition = true;
} else {
const preferredPosition = value.map(placement => POSITION_MAP[placement]);
this.positions = [...preferredPosition, ...DEFAULT_POPOVER_POSITIONS];
}
} }
} }
@ -448,6 +466,7 @@ export class TbPopoverComponent<T = any> implements OnDestroy, OnInit {
} }
preferredPlacement: PopoverPlacement = 'top'; preferredPlacement: PopoverPlacement = 'top';
strictPosition = false;
origin!: CdkOverlayOrigin; origin!: CdkOverlayOrigin;
public dir: Direction = 'ltr'; public dir: Direction = 'ltr';
classMap: { [klass: string]: any } = {}; classMap: { [klass: string]: any } = {};
@ -625,6 +644,10 @@ export class TbPopoverComponent<T = any> implements OnDestroy, OnInit {
onComponentChange(component: ComponentRef<any>) { onComponentChange(component: ComponentRef<any>) {
this.tbComponentRef = component; this.tbComponentRef = component;
if (this.strictPosition) {
this.renderer.setStyle(this.tbComponentRef.location.nativeElement, 'display', 'flex');
this.renderer.setStyle(this.tbComponentRef.location.nativeElement, 'height', '100%');
}
this.tbComponentChange.next(component); this.tbComponentChange.next(component);
} }

24
ui-ngx/src/app/shared/components/popover.models.ts

@ -42,13 +42,30 @@ export const popoverMotion: AnimationTriggerMetadata = trigger('popoverMotion',
]) ])
]); ]);
export const PopoverPlacements = ['top', 'topLeft', 'topRight', 'right', 'rightTop', 'rightBottom', 'bottom', 'bottomLeft', 'bottomRight', 'left', 'leftTop', 'leftBottom'] as const; export const PopoverPlacements = ['top', 'topLeft', 'topRight', 'right', 'rightTop',
'rightBottom', 'bottom', 'bottomLeft', 'bottomRight', 'left', 'leftTop', 'leftBottom'] as const;
type PopoverPlacementTuple = typeof PopoverPlacements; type PopoverPlacementTuple = typeof PopoverPlacements;
export type PopoverPlacement = PopoverPlacementTuple[number]; export type PopoverPlacement = PopoverPlacementTuple[number];
export const StrictPopoverPlacements = ['topOnly', 'topLeftOnly', 'topRightOnly', 'rightOnly', 'rightTopOnly',
'rightBottomOnly', 'bottomOnly', 'bottomLeftOnly', 'bottomRightOnly', 'leftOnly', 'leftTopOnly', 'leftBottomOnly'] as const;
type StrictPopoverPlacementTuple = typeof StrictPopoverPlacements;
export type StrictPopoverPlacement = StrictPopoverPlacementTuple[number];
export type PopoverPreferredPlacement = PopoverPlacement | PopoverPlacement[] | StrictPopoverPlacement | StrictPopoverPlacement[];
export const DEFAULT_POPOVER_POSITIONS = [POSITION_MAP.top, POSITION_MAP.right, POSITION_MAP.bottom, POSITION_MAP.left]; export const DEFAULT_POPOVER_POSITIONS = [POSITION_MAP.top, POSITION_MAP.right, POSITION_MAP.bottom, POSITION_MAP.left];
export function getPlacementName(position: ConnectedOverlayPositionChange): PopoverPlacement | undefined { export const isStrictPopoverPlacement = (placement: string): boolean =>
StrictPopoverPlacements.includes(placement as StrictPopoverPlacement);
export const convertStrictPopoverPlacement = (placement: StrictPopoverPlacement): PopoverPlacement => {
const result = placement.substring(0, placement.length - 4);
return result as PopoverPlacement;
};
export const getPlacementName = (position: ConnectedOverlayPositionChange): PopoverPlacement | undefined => {
for (const placement in POSITION_MAP) { for (const placement in POSITION_MAP) {
if ( if (
position.connectionPair.originX === POSITION_MAP[placement].originX && position.connectionPair.originX === POSITION_MAP[placement].originX &&
@ -60,9 +77,10 @@ export function getPlacementName(position: ConnectedOverlayPositionChange): Popo
} }
} }
return undefined; return undefined;
} };
export interface PropertyMapping { export interface PropertyMapping {
// @ts-ignore
[key: string]: [string, () => unknown]; [key: string]: [string, () => unknown];
} }

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

@ -26,7 +26,12 @@ import {
Type, Type,
ViewContainerRef ViewContainerRef
} from '@angular/core'; } from '@angular/core';
import { PopoverPlacement, PopoverWithTrigger } from '@shared/components/popover.models'; import {
PopoverPlacement,
PopoverPreferredPlacement,
PopoverWithTrigger,
StrictPopoverPlacement
} from '@shared/components/popover.models';
import { TbPopoverComponent } from '@shared/components/popover.component'; import { TbPopoverComponent } from '@shared/components/popover.component';
import { ComponentType } from '@angular/cdk/portal'; import { ComponentType } from '@angular/cdk/portal';
import { HELP_MARKDOWN_COMPONENT_TOKEN } from '@shared/components/tokens'; import { HELP_MARKDOWN_COMPONENT_TOKEN } from '@shared/components/tokens';
@ -63,8 +68,9 @@ export class TbPopoverService {
} }
displayPopover<T>(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, displayPopover<T>(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef,
componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true, componentType: Type<T>, preferredPlacement: PopoverPreferredPlacement = 'top',
injector?: Injector, context?: any, overlayStyle: any = {}, popoverStyle: any = {}, style?: any, hideOnClickOutside = true, injector?: Injector, context?: any, overlayStyle: any = {},
popoverStyle: any = {}, style?: any,
showCloseButton = true, visibleFn: (visible: boolean) => void = () => {}): TbPopoverComponent<T> { showCloseButton = true, visibleFn: (visible: boolean) => void = () => {}): TbPopoverComponent<T> {
const componentRef = this.createPopoverRef(hostView); const componentRef = this.createPopoverRef(hostView);
return this.displayPopoverWithComponentRef(componentRef, trigger, renderer, componentType, preferredPlacement, hideOnClickOutside, return this.displayPopoverWithComponentRef(componentRef, trigger, renderer, componentType, preferredPlacement, hideOnClickOutside,
@ -72,7 +78,7 @@ export class TbPopoverService {
} }
displayPopoverWithComponentRef<T>(componentRef: ComponentRef<TbPopoverComponent>, trigger: Element, renderer: Renderer2, displayPopoverWithComponentRef<T>(componentRef: ComponentRef<TbPopoverComponent>, trigger: Element, renderer: Renderer2,
componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', componentType: Type<T>, preferredPlacement: PopoverPreferredPlacement = 'top',
hideOnClickOutside = true, injector?: Injector, context?: any, overlayStyle: any = {}, hideOnClickOutside = true, injector?: Injector, context?: any, overlayStyle: any = {},
popoverStyle: any = {}, style?: any, showCloseButton = true, popoverStyle: any = {}, style?: any, showCloseButton = true,
visibleFn: (visible: boolean) => void = () => {}): TbPopoverComponent<T> { visibleFn: (visible: boolean) => void = () => {}): TbPopoverComponent<T> {
@ -116,7 +122,7 @@ export class TbPopoverService {
helpContent = '', helpContent = '',
visibleFn: (visible: boolean) => void = () => {}, visibleFn: (visible: boolean) => void = () => {},
readyFn: (ready: boolean) => void = () => {}, readyFn: (ready: boolean) => void = () => {},
preferredPlacement: PopoverPlacement = 'bottom', preferredPlacement: PopoverPreferredPlacement = 'bottom',
overlayStyle: any = {}, helpStyle: any = {}) { overlayStyle: any = {}, helpStyle: any = {}) {
if (this.hasPopover(trigger)) { if (this.hasPopover(trigger)) {
this.hidePopover(trigger); this.hidePopover(trigger);

109
ui-ngx/src/app/shared/components/value-input.component.html

@ -16,64 +16,81 @@
--> -->
<form #inputForm="ngForm"> <form #inputForm="ngForm">
<section fxLayout="row" fxLayoutGap="8px"> <section [fxLayout]="computedLayout" fxLayoutGap="8px">
<mat-form-field fxFlex="40" class="mat-block tb-value-type"> <mat-form-field *ngIf="showValueType" appearance="outline" subscriptSizing="dynamic" class="tb-value-type tb-inline-field" [class]="computedLayout">
<mat-label translate>value.type</mat-label> <mat-select [disabled]="disabled" name="valueType" [(ngModel)]="valueType" (ngModelChange)="onValueTypeChanged()">
<mat-select [disabled]="disabled" matInput name="valueType" [(ngModel)]="valueType" (ngModelChange)="onValueTypeChanged()">
<mat-select-trigger> <mat-select-trigger>
<mat-icon svgIcon="{{ valueTypes.get(valueType).icon }}"></mat-icon> <div class="tb-value-type-row">
<span>{{ valueTypes.get(valueType).name | translate }}</span> <mat-icon class="tb-mat-18" svgIcon="{{ valueTypes.get(valueType).icon }}"></mat-icon>
<span>{{ valueTypes.get(valueType).name | translate }}</span>
</div>
</mat-select-trigger> </mat-select-trigger>
<mat-option *ngFor="let valueType of valueTypeKeys" [value]="valueType"> <mat-option *ngFor="let valueType of valueTypeKeys" [value]="valueType">
<mat-icon svgIcon="{{ valueTypes.get(valueTypeEnum[valueType]).icon }}"></mat-icon> <mat-icon class="tb-mat-20" svgIcon="{{ valueTypes.get(valueTypeEnum[valueType]).icon }}"></mat-icon>
<span>{{ valueTypes.get(valueTypeEnum[valueType]).name | translate }}</span> <span>{{ valueTypes.get(valueTypeEnum[valueType]).name | translate }}</span>
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field *ngIf="valueType === valueTypeEnum.STRING" fxFlex="60" class="mat-block"> <mat-form-field *ngIf="valueType === valueTypeEnum.STRING" fxFlex appearance="outline" subscriptSizing="dynamic" class="tb-inline-field flex tb-suffix-absolute">
<mat-label translate>value.string-value</mat-label> <input [disabled]="disabled" matInput required name="value" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
<input [disabled]="disabled" matInput required name="value" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"/> placeholder="{{ 'value.string-value' | translate }}*"/>
<mat-error *ngIf="value.hasError('required')"> <mat-icon matSuffix
{{ (requiredText ? requiredText : 'value.string-value-required') | translate }} matTooltipPosition="above"
</mat-error> matTooltipClass="tb-error-tooltip"
[matTooltip]="(requiredText ? requiredText : 'value.string-value-required') | translate"
*ngIf="value.hasError('required') && value.control.touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field> </mat-form-field>
<mat-form-field *ngIf="valueType === valueTypeEnum.INTEGER" fxFlex="60" class="mat-block"> <mat-form-field *ngIf="valueType === valueTypeEnum.INTEGER" fxFlex appearance="outline" subscriptSizing="dynamic" class="tb-inline-field flex tb-suffix-absolute number">
<mat-label translate>value.integer-value</mat-label> <input [disabled]="disabled" matInput required name="value" type="number" step="1" pattern="^-?[0-9]+$" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
<input [disabled]="disabled" matInput required name="value" type="number" step="1" pattern="^-?[0-9]+$" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"/> placeholder="{{ 'value.integer-value' | translate }}*"/>
<mat-error *ngIf="value.hasError('required')"> <mat-icon matSuffix
{{ (requiredText ? requiredText : 'value.integer-value-required') | translate }} matTooltipPosition="above"
</mat-error> matTooltipClass="tb-error-tooltip"
<mat-error *ngIf="value.hasError('pattern')"> [matTooltip]="value.hasError('required') ? ((requiredText ? requiredText : 'value.integer-value-required') | translate) :
{{ 'value.invalid-integer-value' | translate }} ('value.invalid-integer-value' | translate)"
</mat-error> *ngIf="value.control.invalid && value.control.touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field> </mat-form-field>
<mat-form-field *ngIf="valueType === valueTypeEnum.DOUBLE" fxFlex="60" class="mat-block"> <mat-form-field *ngIf="valueType === valueTypeEnum.DOUBLE" fxFlex appearance="outline" subscriptSizing="dynamic" class="tb-inline-field flex tb-suffix-absolute number">
<mat-label translate>value.double-value</mat-label> <input [disabled]="disabled" matInput required name="value" type="number" step="any" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
<input [disabled]="disabled" matInput required name="value" type="number" step="any" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"/> placeholder="{{ 'value.double-value' | translate }}*"/>
<mat-error *ngIf="value.hasError('required')"> <mat-icon matSuffix
{{ (requiredText ? requiredText : 'value.double-value-required') | translate }} matTooltipPosition="above"
</mat-error> matTooltipClass="tb-error-tooltip"
[matTooltip]="(requiredText ? requiredText : 'value.double-value-required') | translate"
*ngIf="value.hasError('required') && value.control.touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field> </mat-form-field>
<div fxLayout="column" fxLayoutAlign="center" fxFlex="60" *ngIf="valueType === valueTypeEnum.BOOLEAN"> <mat-chip-listbox *ngIf="valueType === valueTypeEnum.BOOLEAN" fxFlex class="tb-boolean-input center-stretch"
<mat-checkbox [disabled]="disabled" name="value" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()" style="min-width: 180px;"> [disabled]="disabled" name="value" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()">
{{ (modelValue ? 'value.true' : 'value.false') | translate }} <mat-chip-option [selectable]="!modelValue" [value]="true">{{ trueLabel | translate }}</mat-chip-option>
</mat-checkbox> <mat-chip-option [selectable]="modelValue" [value]="false">{{ falseLabel | translate }}</mat-chip-option>
</div> </mat-chip-listbox>
<div fxLayout="row" fxLayoutAlign="center" fxFlex="60" *ngIf="valueType === valueTypeEnum.JSON" class="mat-block"> <div *ngIf="valueType === valueTypeEnum.JSON" fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field fxFlex class="mat-block"> <mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic" class="tb-inline-field flex tb-suffix-absolute">
<mat-label translate>value.json-value</mat-label>
<input [disabled]="disabled" matInput tb-json-to-string required name="value" #value="ngModel" <input [disabled]="disabled" matInput tb-json-to-string required name="value" #value="ngModel"
[(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"/> [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()" placeholder="{{ 'value.json-value' | translate }}*"/>
<button matSuffix mat-icon-button (click)="openEditJSONDialog($event)"> <mat-icon matSuffix
<mat-icon>open_in_new</mat-icon> matTooltipPosition="above"
</button> matTooltipClass="tb-error-tooltip"
<mat-error *ngIf="value.hasError('required') && !value.hasError('invalidJSON')"> [matTooltip]="value.hasError('invalidJSON') ?
{{ (requiredText ? requiredText : 'value.json-value-required') | translate }} ('value.json-value-invalid' | translate) :
</mat-error> ((requiredText ? requiredText : 'value.json-value-required') | translate)"
<mat-error *ngIf="value.hasError('invalidJSON')"> *ngIf="value.control.invalid && value.control.touched"
{{ 'value.json-value-invalid' | translate }} class="tb-error">
</mat-error> warning
</mat-icon>
</mat-form-field> </mat-form-field>
<button mat-icon-button class="tb-mat-32" (click)="openEditJSONDialog($event)">
<mat-icon class="tb-mat-20">open_in_new</mat-icon>
</button>
</div> </div>
</section> </section>
</form> </form>

32
ui-ngx/src/app/shared/components/value-input.component.scss

@ -13,14 +13,36 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
:host {
.tb-boolean-input {
min-width: 210px;
}
}
:host ::ng-deep { :host ::ng-deep {
.mat-mdc-form-field.tb-value-type { .mat-mdc-form-field.tb-value-type {
&.row {
width: 140px;
min-width: 140px;
}
&.column {
flex: 1;
width: auto;
}
.mat-mdc-form-field-infix {
max-height: 40px;
}
mat-select-trigger { mat-select-trigger {
.mat-icon { .tb-value-type-row {
vertical-align: bottom; display: flex;
margin-right: 16px; flex-direction: row;
svg { align-items: center;
vertical-align: initial; justify-content: flex-start;
gap: 10px;
.mat-icon {
color: rgba(0, 0, 0, 0.38);
vertical-align: bottom;
} }
} }
} }

97
ui-ngx/src/app/shared/components/value-input.component.ts

@ -14,15 +14,24 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; import { Component, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgForm } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgForm } from '@angular/forms';
import { ValueType, valueTypesMap } from '@shared/models/constants'; import { resolveBreakpoint, ValueType, valueTypesMap } from '@shared/models/constants';
import { isObject } from '@core/utils'; import { isObject } from '@core/utils';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { import {
JsonObjectEditDialogComponent, JsonObjectEditDialogComponent,
JsonObjectEditDialogData JsonObjectEditDialogData
} from '@shared/components/dialog/json-object-edit-dialog.component'; } from '@shared/components/dialog/json-object-edit-dialog.component';
import { BreakpointObserver } from '@angular/cdk/layout';
import { Subscription } from 'rxjs';
type Layout = 'column' | 'row';
export interface ValueInputLayout {
layout: Layout;
breakpoints?: {[breakpoint: string]: Layout};
}
@Component({ @Component({
selector: 'tb-value-input', selector: 'tb-value-input',
@ -36,33 +45,67 @@ import {
} }
] ]
}) })
export class ValueInputComponent implements OnInit, ControlValueAccessor { export class ValueInputComponent implements OnInit, OnDestroy, ControlValueAccessor {
@Input()
disabled: boolean;
@Input()
requiredText: string;
@Input()
valueType: ValueType;
@Input() disabled: boolean; @Input()
trueLabel = 'value.true';
@Input() requiredText: string; @Input()
falseLabel = 'value.false';
@Input()
layout: ValueInputLayout | Layout = 'row';
@ViewChild('inputForm', {static: true}) inputForm: NgForm; @ViewChild('inputForm', {static: true}) inputForm: NgForm;
modelValue: any; modelValue: any;
valueType: ValueType;
public valueTypeEnum = ValueType; public valueTypeEnum = ValueType;
valueTypeKeys = Object.keys(ValueType); valueTypeKeys = Object.keys(ValueType);
valueTypes = valueTypesMap; valueTypes = valueTypesMap;
showValueType = true;
computedLayout: Layout;
private propagateChange = null; private propagateChange = null;
private _subscription: Subscription;
constructor( constructor(
private breakpointObserver: BreakpointObserver,
public dialog: MatDialog, public dialog: MatDialog,
) { ) {
} }
ngOnInit(): void { ngOnInit(): void {
this._subscription = new Subscription();
this.showValueType = !this.valueType;
this.computedLayout = this._computeLayout();
if (typeof this.layout === 'object' && this.layout.breakpoints) {
const breakpoints = Object.keys(this.layout.breakpoints);
this._subscription.add(this.breakpointObserver.observe(breakpoints.map(breakpoint => resolveBreakpoint(breakpoint))).subscribe(
() => {
this.computedLayout = this._computeLayout();
}
));
}
}
ngOnDestroy() {
this._subscription.unsubscribe();
} }
openEditJSONDialog($event: Event) { openEditJSONDialog($event: Event) {
@ -100,18 +143,20 @@ export class ValueInputComponent implements OnInit, ControlValueAccessor {
writeValue(value: any): void { writeValue(value: any): void {
this.modelValue = value; this.modelValue = value;
if (this.modelValue === true || this.modelValue === false) { if (this.showValueType) {
this.valueType = ValueType.BOOLEAN; if (this.modelValue === true || this.modelValue === false) {
} else if (typeof this.modelValue === 'number') { this.valueType = ValueType.BOOLEAN;
if (this.modelValue.toString().indexOf('.') === -1) { } else if (typeof this.modelValue === 'number') {
this.valueType = ValueType.INTEGER; if (this.modelValue.toString().indexOf('.') === -1) {
this.valueType = ValueType.INTEGER;
} else {
this.valueType = ValueType.DOUBLE;
}
} else if (isObject(this.modelValue)) {
this.valueType = ValueType.JSON;
} else { } else {
this.valueType = ValueType.DOUBLE; this.valueType = ValueType.STRING;
} }
} else if (isObject(this.modelValue)) {
this.valueType = ValueType.JSON;
} else {
this.valueType = ValueType.STRING;
} }
} }
@ -140,4 +185,22 @@ export class ValueInputComponent implements OnInit, ControlValueAccessor {
this.updateView(); this.updateView();
} }
private _computeLayout(): Layout {
if (typeof this.layout !== 'object') {
return this.layout;
} else {
let layout = this.layout.layout;
if (this.layout.breakpoints) {
for (const breakpoint of Object.keys(this.layout.breakpoints)) {
const breakpointValue = resolveBreakpoint(breakpoint);
if (this.breakpointObserver.isMatched(breakpointValue)) {
layout = this.layout.breakpoints[breakpoint];
break;
}
}
}
return layout;
}
}
} }

31
ui-ngx/src/app/shared/import-export/import-export.service.ts

@ -229,11 +229,10 @@ export class ImportExportService {
const originalSize = widgetItem.originalSize; const originalSize = widgetItem.originalSize;
const datasourceAliases = aliasesInfo.datasourceAliases; const datasourceAliases = aliasesInfo.datasourceAliases;
const targetDeviceAliases = aliasesInfo.targetDeviceAliases; if (datasourceAliases || aliasesInfo.targetDeviceAlias) {
if (datasourceAliases || targetDeviceAliases) {
const entityAliases: EntityAliases = {}; const entityAliases: EntityAliases = {};
const datasourceAliasesMap: {[aliasId: string]: number} = {}; const datasourceAliasesMap: {[aliasId: string]: number} = {};
const targetDeviceAliasesMap: {[aliasId: string]: number} = {}; let targetDeviceAliasId: string;
let aliasId: string; let aliasId: string;
let datasourceIndex: number; let datasourceIndex: number;
if (datasourceAliases) { if (datasourceAliases) {
@ -244,13 +243,10 @@ export class ImportExportService {
entityAliases[aliasId] = {id: aliasId, ...datasourceAliases[datasourceIndex]}; entityAliases[aliasId] = {id: aliasId, ...datasourceAliases[datasourceIndex]};
} }
} }
if (targetDeviceAliases) { if (aliasesInfo.targetDeviceAlias) {
for (const strIndex of Object.keys(targetDeviceAliases)) { aliasId = this.utils.guid();
datasourceIndex = Number(strIndex); targetDeviceAliasId = aliasId;
aliasId = this.utils.guid(); entityAliases[aliasId] = {id: aliasId, ...aliasesInfo.targetDeviceAlias};
targetDeviceAliasesMap[aliasId] = datasourceIndex;
entityAliases[aliasId] = {id: aliasId, ...targetDeviceAliases[datasourceIndex]};
}
} }
const aliasIds = Object.keys(entityAliases); const aliasIds = Object.keys(entityAliases);
if (aliasIds.length > 0) { if (aliasIds.length > 0) {
@ -267,9 +263,8 @@ export class ImportExportService {
if (isDefined(datasourceAliasesMap[id])) { if (isDefined(datasourceAliasesMap[id])) {
index = datasourceAliasesMap[id]; index = datasourceAliasesMap[id];
datasourceAliases[index] = entityAlias; datasourceAliases[index] = entityAlias;
} else if (isDefined(targetDeviceAliasesMap[id])) { } else if (targetDeviceAliasId === id) {
index = targetDeviceAliasesMap[id]; aliasesInfo.targetDeviceAlias = entityAlias;
targetDeviceAliases[index] = entityAlias;
} }
} }
return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget,
@ -965,19 +960,15 @@ export class ImportExportService {
private prepareAliasesInfo(aliasesInfo: AliasesInfo): AliasesInfo { private prepareAliasesInfo(aliasesInfo: AliasesInfo): AliasesInfo {
const datasourceAliases = aliasesInfo.datasourceAliases; const datasourceAliases = aliasesInfo.datasourceAliases;
const targetDeviceAliases = aliasesInfo.targetDeviceAliases; if (datasourceAliases || aliasesInfo.targetDeviceAlias) {
if (datasourceAliases || targetDeviceAliases) {
if (datasourceAliases) { if (datasourceAliases) {
for (const strIndex of Object.keys(datasourceAliases)) { for (const strIndex of Object.keys(datasourceAliases)) {
const datasourceIndex = Number(strIndex); const datasourceIndex = Number(strIndex);
datasourceAliases[datasourceIndex] = this.prepareEntityAlias(datasourceAliases[datasourceIndex]); datasourceAliases[datasourceIndex] = this.prepareEntityAlias(datasourceAliases[datasourceIndex]);
} }
} }
if (targetDeviceAliases) { if (aliasesInfo.targetDeviceAlias) {
for (const strIndex of Object.keys(targetDeviceAliases)) { aliasesInfo.targetDeviceAlias = this.prepareEntityAlias(aliasesInfo.targetDeviceAlias);
const datasourceIndex = Number(strIndex);
targetDeviceAliases[datasourceIndex] = this.prepareEntityAlias(targetDeviceAliases[datasourceIndex]);
}
} }
} }
return aliasesInfo; return aliasesInfo;

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

@ -194,7 +194,7 @@ export interface EntityAliasInfo {
export interface AliasesInfo { export interface AliasesInfo {
datasourceAliases: {[datasourceIndex: number]: EntityAliasInfo}; datasourceAliases: {[datasourceIndex: number]: EntityAliasInfo};
targetDeviceAliases: {[targetDeviceAliasIndex: number]: EntityAliasInfo}; targetDeviceAlias: EntityAliasInfo;
} }
export interface EntityAlias extends EntityAliasInfo { export interface EntityAlias extends EntityAliasInfo {

125
ui-ngx/src/app/shared/models/rpc-widget-settings.models.ts

@ -0,0 +1,125 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { BackgroundSettings } from '@shared/models/widget-settings.models';
export enum RpcInitialStateAction {
DO_NOTHING = 'DO_NOTHING',
EXECUTE_RPC = 'EXECUTE_RPC',
GET_ATTRIBUTE = 'GET_ATTRIBUTE',
GET_TIME_SERIES = 'GET_TIME_SERIES'
}
export const rpcInitialStateActions = Object.keys(RpcInitialStateAction) as RpcInitialStateAction[];
export const rpcInitialStateTranslations = new Map<RpcInitialStateAction, string>(
[
[RpcInitialStateAction.DO_NOTHING, 'widgets.rpc-state.do-nothing'],
[RpcInitialStateAction.EXECUTE_RPC, 'widgets.rpc-state.execute-rpc'],
[RpcInitialStateAction.GET_ATTRIBUTE, 'widgets.rpc-state.get-attribute'],
[RpcInitialStateAction.GET_TIME_SERIES, 'widgets.rpc-state.get-time-series']
]
);
export interface RpcSettings {
method: string;
requestTimeout: number;
requestPersistent: boolean;
persistentPollingInterval: number;
}
export interface RpcTelemetrySettings {
key: string;
}
export interface RpcGetAttributeSettings extends RpcTelemetrySettings {
scope: AttributeScope | null;
}
export interface RpcSetAttributeSettings extends RpcTelemetrySettings {
scope: AttributeScope.SERVER_SCOPE | AttributeScope.SHARED_SCOPE;
}
export enum RpcDataToStateType {
NONE = 'NONE',
FUNCTION = 'FUNCTION'
}
export interface RpcDataToStateSettings {
type: RpcDataToStateType;
dataToStateFunction: string;
compareToValue?: any;
}
export interface RpcActionSettings {
actionLabel?: string;
}
export interface RpcInitialStateSettings<V> extends RpcActionSettings {
action: RpcInitialStateAction;
defaultValue: V;
executeRpc: RpcSettings;
getAttribute: RpcGetAttributeSettings;
getTimeSeries: RpcTelemetrySettings;
dataToState: RpcDataToStateSettings;
}
export enum RpcUpdateStateAction {
EXECUTE_RPC = 'EXECUTE_RPC',
SET_ATTRIBUTE = 'SET_ATTRIBUTE',
ADD_TIME_SERIES = 'ADD_TIME_SERIES'
}
export const rpcUpdateStateActions = Object.keys(RpcUpdateStateAction) as RpcUpdateStateAction[];
export const rpcUpdateStateTranslations = new Map<RpcUpdateStateAction, string>(
[
[RpcUpdateStateAction.EXECUTE_RPC, 'widgets.rpc-state.execute-rpc'],
[RpcUpdateStateAction.SET_ATTRIBUTE, 'widgets.rpc-state.set-attribute'],
[RpcUpdateStateAction.ADD_TIME_SERIES, 'widgets.rpc-state.add-time-series']
]
);
export enum RpcStateToParamsType {
CONSTANT = 'CONSTANT',
FUNCTION = 'FUNCTION',
NONE = 'NONE'
}
export interface RpcStateToParamsSettings {
type: RpcStateToParamsType;
constantValue: any;
stateToParamsFunction: string;
}
export interface RpcUpdateStateSettings extends RpcActionSettings {
action: RpcUpdateStateAction;
executeRpc: RpcSettings;
setAttribute: RpcSetAttributeSettings;
putTimeSeries: RpcTelemetrySettings;
stateToParams: RpcStateToParamsSettings;
}
export interface RpcStateBehaviourSettings<V> {
initialState: RpcInitialStateSettings<V>;
updateStateByValue: (value: V) => RpcUpdateStateSettings;
}
export interface RpcStateWidgetSettings<V> {
initialState: RpcInitialStateSettings<V>;
background: BackgroundSettings;
}

10
ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts

@ -88,6 +88,16 @@ export const telemetryTypeTranslations = new Map<TelemetryType, string>(
] ]
); );
export const telemetryTypeTranslationsShort = new Map<TelemetryType, string>(
[
[LatestTelemetry.LATEST_TELEMETRY, 'attribute.scope-telemetry-short'],
[AttributeScope.CLIENT_SCOPE, 'attribute.scope-client-short'],
[AttributeScope.SERVER_SCOPE, 'attribute.scope-server-short'],
[AttributeScope.SHARED_SCOPE, 'attribute.scope-shared-short']
]
);
export const isClientSideTelemetryType = new Map<TelemetryType, boolean>( export const isClientSideTelemetryType = new Map<TelemetryType, boolean>(
[ [
[LatestTelemetry.LATEST_TELEMETRY, true], [LatestTelemetry.LATEST_TELEMETRY, true],

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

@ -183,6 +183,7 @@ export interface WidgetTypeParameters {
hideDataSettings?: boolean; hideDataSettings?: boolean;
defaultDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; defaultDataKeysFunction?: (configComponent: any, configData: any) => DataKey[];
defaultLatestDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; defaultLatestDataKeysFunction?: (configComponent: any, configData: any) => DataKey[];
displayRpcMessageToast?: boolean;
} }
export interface WidgetControllerDescriptor { export interface WidgetControllerDescriptor {
@ -409,6 +410,22 @@ export interface Datasource {
[key: string]: any; [key: string]: any;
} }
export enum TargetDeviceType {
device = 'device',
entity = 'entity'
}
export interface TargetDevice {
type?: TargetDeviceType;
deviceId?: string;
entityAliasId?: string;
}
export const targetDeviceValid = (targetDevice?: TargetDevice): boolean =>
!!targetDevice && !!targetDevice.type &&
((targetDevice.type === TargetDeviceType.device && !!targetDevice.deviceId) ||
(targetDevice.type === TargetDeviceType.entity && !!targetDevice.entityAliasId));
export const datasourcesHasAggregation = (datasources?: Array<Datasource>): boolean => { export const datasourcesHasAggregation = (datasources?: Array<Datasource>): boolean => {
if (datasources) { if (datasources) {
const foundDatasource = datasources.find(datasource => { const foundDatasource = datasources.find(datasource => {
@ -697,7 +714,7 @@ export interface WidgetConfig {
alarmSource?: Datasource; alarmSource?: Datasource;
alarmFilterConfig?: AlarmFilterConfig; alarmFilterConfig?: AlarmFilterConfig;
datasources?: Array<Datasource>; datasources?: Array<Datasource>;
targetDeviceAliasIds?: Array<string>; targetDevice?: TargetDevice;
[key: string]: any; [key: string]: any;
} }

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

@ -740,6 +740,11 @@
"scope-client": "Client attributes", "scope-client": "Client attributes",
"scope-server": "Server attributes", "scope-server": "Server attributes",
"scope-shared": "Shared attributes", "scope-shared": "Shared attributes",
"scope-client-short": "Client",
"scope-server-short": "Server",
"scope-shared-short": "Shared",
"scope-latest-short": "Latest",
"scope-any": "Any",
"add": "Add attribute", "add": "Add attribute",
"key": "Key", "key": "Key",
"key-max-length": "Key should be less than 256", "key-max-length": "Key should be less than 256",
@ -3718,6 +3723,15 @@
"pkcs-12": "PKCS #12" "pkcs-12": "PKCS #12"
} }
}, },
"rpc": {
"error": {
"target-device-is-not-set": "Target device is not set!",
"invalid-target-entity": "RPC commands are not supported by <b>{{entityType}}</b> entity.",
"failed-to-resolve-target-device": "Failed to resolve target device!",
"request-timeout": "Request Timeout.",
"rpc-http-error": "Error: {{status}} - {{statusText}}"
}
},
"rulechain": { "rulechain": {
"rulechain": "Rule chain", "rulechain": "Rule chain",
"rulechain-events": "Rule chain events", "rulechain-events": "Rule chain events",
@ -5986,6 +6000,60 @@
"min-value": "Minimum value", "min-value": "Minimum value",
"max-value": "Maximum value" "max-value": "Maximum value"
}, },
"rpc-state": {
"initial-state": "Initial state",
"initial-state-hint": "Action to get the initial value of the component.",
"turn-on": "Turn 'On'",
"turn-on-hint": "Action performed to turn ON the component.",
"turn-off": "Turn 'Off'",
"turn-off-hint": "Action performed to turn OFF the component.",
"on": "On",
"off": "Off",
"do-nothing": "Do nothing",
"execute-rpc": "Execute RPC",
"get-attribute": "Get attribute",
"set-attribute": "Set attribute",
"get-time-series": "Get time-series",
"add-time-series": "Add time-series",
"execute-rpc-text": "Execute RPC method '{{methodName}}'",
"get-attribute-text": "Use attribute '{{key}}'",
"get-time-series-text": "Use time-series '{{key}}'",
"set-attribute-to-value-text": "Set '{{key}}' attribute to: {{value}}",
"add-time-series-value-text": "Add '{{key}}' time-series value: {{value}}",
"set-attribute-text": "Set '{{key}}' attribute",
"add-time-series-text": "Add '{{key}}' time-series",
"action": "Action",
"value": "Value",
"init-value-hint": "Value that will be set until device sends data.",
"method": "Method",
"method-name-required": "Method name is required.",
"request-timeout-ms": "RPC request timeout (ms)",
"request-timeout-required": "Request timeout is required.",
"min-request-timeout-error": "Request timeout value should be greater or equal 5000 ms (5 seconds).",
"request-persistent": "RPC request persistent",
"persistent-polling-interval": "Persistent polling interval (ms)",
"persistent-polling-interval-hint": "Polling interval (ms) to get persistent RPC command response",
"persistent-polling-interval-required": "Persistent polling interval is required.",
"min-persistent-polling-interval-error": "Persistent polling interval value should be greater or equal 1000 ms (1 second).",
"attribute-scope": "Attribute scope",
"attribute-key": "Attribute key",
"attribute-key-required": "Attribute key is required.",
"time-series-key": "Time-series key",
"time-series-key-required": "Time-series key is required.",
"action-result-converter": "Action result converter",
"converter-none": "None",
"converter-function": "Function",
"converter-constant": "Constant",
"parse-value-function": "Parse value function",
"on-when-result-is": "'On' when result is",
"parameters": "Parameters",
"convert-value-function": "Convert value function",
"error": {
"target-entity-is-not-set": "Target entity is not set!",
"failed-to-perform-action": "Failed to perform the <b>{{ actionLabel }}</b> action.",
"invalid-attribute-scope": "{{scope}} attribute scope is not supported by <b>{{entityType}}</b> entity."
}
},
"maps": { "maps": {
"select-entity": "Select entity", "select-entity": "Select entity",
"select-entity-hint": "Hint: after selection click at the map to set position", "select-entity-hint": "Hint: after selection click at the map to set position",
@ -6219,6 +6287,24 @@
"label-position-left": "Left", "label-position-left": "Left",
"label-position-top": "Top" "label-position-top": "Top"
}, },
"single-switch": {
"behavior": "Behavior",
"layout": "Layout",
"layout-right": "Right",
"layout-left": "Left",
"layout-centered": "Centered",
"auto-scale": "Auto scale",
"label": "Label",
"icon": "Icon",
"switch-color": "Switch color",
"on": "On",
"off": "Off",
"disabled": "Disabled",
"tumbler-color": "Tumbler color",
"on-label": "On label",
"off-label": "Off label",
"switch": "Switch"
},
"value-card": { "value-card": {
"layout": "Layout", "layout": "Layout",
"layout-square": "Square", "layout-square": "Square",

21
ui-ngx/src/assets/widget/single-switch/centered-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

21
ui-ngx/src/assets/widget/single-switch/left-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

21
ui-ngx/src/assets/widget/single-switch/right-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

83
ui-ngx/src/form.scss

@ -18,6 +18,9 @@
@mixin form-row-column() { @mixin form-row-column() {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
&.align-start {
align-items: stretch;
}
gap: 12px; gap: 12px;
padding: 12px 7px 12px 16px; padding: 12px 7px 12px 16px;
} }
@ -76,6 +79,9 @@
} }
> .mat-expansion-panel { > .mat-expansion-panel {
padding: 16px; padding: 16px;
&.no-padding {
padding: 0;
}
.mat-expansion-panel-header { .mat-expansion-panel-header {
.mat-slide { .mat-slide {
margin: 0; margin: 0;
@ -101,8 +107,9 @@
background: none; background: none;
} }
.mat-expansion-indicator { .mat-expansion-indicator {
height: 32px; height: 24px;
padding: 2px; padding: 2px;
line-height: 14px;
} }
} }
> .mat-expansion-panel-header-description { > .mat-expansion-panel-header-description {
@ -113,9 +120,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
padding: 16px 0 0 !important; padding: 0 !important;
> :first-child {
margin-top: 16px;
@media #{$mat-xs} {
margin-top: 12px;
}
}
@media #{$mat-xs} { @media #{$mat-xs} {
padding: 12px 0 0 !important;
gap: 8px; gap: 8px;
} }
} }
@ -166,10 +178,16 @@
@include form-row-column-breakpoint($mat-lt-md) @include form-row-column-breakpoint($mat-lt-md)
} }
} }
&.align-start {
align-items: flex-start;
}
&.no-border { &.no-border {
border: none; border: none;
border-radius: 0; border-radius: 0;
} }
&.no-gap {
gap: 0;
}
&.no-padding { &.no-padding {
padding: 0; padding: 0;
} }
@ -200,6 +218,10 @@
min-width: fit-content; min-width: fit-content;
} }
} }
.fixed-title-height {
min-height: 40px;
line-height: 40px;
}
.mat-slide:only-child { .mat-slide:only-child {
margin: 8px 0; margin: 8px 0;
} }
@ -340,6 +362,15 @@
} }
} }
} }
&.tb-suffix-absolute {
.mat-mdc-text-field-wrapper {
.mat-mdc-form-field-icon-suffix {
> .mat-icon {
right: 16px;
}
}
}
}
} }
&.tb-chips { &.tb-chips {
&.flex { &.flex {
@ -385,6 +416,27 @@
} }
} }
} }
&.tb-suffix-absolute {
.mat-mdc-text-field-wrapper {
.mat-mdc-form-field-icon-suffix {
padding: 0;
> .mat-icon {
position: absolute;
right: 0;
}
}
}
&.mat-mdc-form-field-has-icon-suffix {
.mat-mdc-text-field-wrapper {
padding-right: 12px;
}
&.number {
.mat-mdc-text-field-wrapper {
padding-right: 4px;
}
}
}
}
} }
.tb-form-table { .tb-form-table {
@ -554,4 +606,29 @@
color: rgba(0, 0, 0, 0.12); color: rgba(0, 0, 0, 0.12);
} }
} }
.mat-mdc-chip-listbox.center-stretch {
.mat-mdc-standard-chip {
flex: 1;
&.mdc-evolution-chip--with-primary-graphic {
.mdc-evolution-chip__action--primary {
width: 100%;
padding-right: 0;
gap: 0;
.mdc-evolution-chip__graphic {
padding: 0;
flex: none;
}
}
&.mdc-evolution-chip--selected {
.mdc-evolution-chip__action--primary {
gap: 4px;
}
.mdc-evolution-chip__graphic {
width: 20px;
}
}
}
}
}
} }

3
ui-ngx/src/styles.scss

@ -364,7 +364,8 @@ mat-icon {
.mat-mdc-tooltip.tb-error-tooltip { .mat-mdc-tooltip.tb-error-tooltip {
.mdc-tooltip__surface { .mdc-tooltip__surface {
background-color: rgba(209, 39, 48, 0.12); background-color: #fff2f3;
box-shadow: -2px 2px 4px 0px rgba(0,0,0,0.2);
color: rgba(209, 39, 48, 1); color: rgba(209, 39, 48, 1);
} }
} }

Loading…
Cancel
Save