Browse Source

Merged with master branch

pull/10011/head
Ivan Raznatovskyi 3 years ago
parent
commit
804a73e13e
  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. 2
      application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java
  6. 4
      application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java
  8. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java
  9. 2
      dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java
  10. 10
      dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java
  11. 9
      dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java
  12. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java
  13. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java
  14. 50
      rule-engine/rule-engine-components/src/main/resources/public/static/rulenode/rulenode-core-config.js
  15. 3
      ui-ngx/angular.json
  16. 35
      ui-ngx/src/app/core/api/alias-controller.ts
  17. 26
      ui-ngx/src/app/core/api/widget-api.models.ts
  18. 355
      ui-ngx/src/app/core/api/widget-subscription.ts
  19. 10
      ui-ngx/src/app/core/http/asset-profile.service.ts
  20. 9
      ui-ngx/src/app/core/http/device-profile.service.ts
  21. 4
      ui-ngx/src/app/core/http/device.service.ts
  22. 46
      ui-ngx/src/app/core/http/entity.service.ts
  23. 35
      ui-ngx/src/app/core/interceptors/global-http-interceptor.ts
  24. 104
      ui-ngx/src/app/core/services/dashboard-utils.service.ts
  25. 29
      ui-ngx/src/app/core/services/item-buffer.service.ts
  26. 26
      ui-ngx/src/app/core/services/utils.service.ts
  27. 30
      ui-ngx/src/app/core/utils.ts
  28. 12
      ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts
  29. 2
      ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html
  30. 7
      ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts
  31. 2
      ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html
  32. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html
  33. 4
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.html
  34. 4
      ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts
  35. 1
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  36. 65
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts
  37. 12
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  38. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts
  39. 214
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html
  40. 231
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts
  41. 38
      ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html
  42. 166
      ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts
  43. 3
      ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts
  44. 5
      ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts
  45. 2
      ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts
  46. 2
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.scss
  47. 626
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widget.models.ts
  48. 42
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html
  49. 204
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.scss
  50. 202
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts
  51. 202
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts
  52. 211
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.html
  53. 176
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.ts
  54. 156
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings.component.ts
  55. 26
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.component.html
  56. 34
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.scss
  57. 47
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-panel.component.scss
  58. 201
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.html
  59. 180
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.ts
  60. 174
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings.component.ts
  61. 29
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  62. 21
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html
  63. 58
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts
  64. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html
  65. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.ts
  66. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html
  67. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts
  68. 198
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html
  69. 157
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts
  70. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html
  71. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.ts
  72. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html
  73. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts
  74. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html
  75. 16
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts
  76. 17
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  77. 17
      ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts
  78. 3
      ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts
  79. 7
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  80. 22
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  81. 33
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  82. 19
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  83. 23
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  84. 24
      ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html
  85. 2
      ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.scss
  86. 8
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html
  87. 8
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html
  88. 25
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts
  89. 2
      ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html
  90. 21
      ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts
  91. 49
      ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts
  92. 29
      ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts
  93. 81
      ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts
  94. 11
      ui-ngx/src/app/shared/components/fullscreen.directive.ts
  95. 9
      ui-ngx/src/app/shared/components/hotkeys.directive.ts
  96. 8
      ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts
  97. 8
      ui-ngx/src/app/shared/components/popover.component.scss
  98. 37
      ui-ngx/src/app/shared/components/popover.component.ts
  99. 24
      ui-ngx/src/app/shared/components/popover.models.ts
  100. 16
      ui-ngx/src/app/shared/components/popover.service.ts

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.http.ResponseEntity;
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.RequestMapping;
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 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_DATA_QUERY_DESCRIPTION;
@ -119,14 +122,16 @@ public class EntityQueryController extends BaseController {
@ApiParam(value = "Include all unique time-series keys to the result.")
@RequestParam("timeseries") boolean isTimeseries,
@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();
checkNotNull(query);
EntityDataPageLink pageLink = query.getPageLink();
if (pageLink.getPageSize() > 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);
}
}

2
application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java

@ -181,7 +181,7 @@ public class DefaultMailService implements MailService {
passwordResetExecutorService.execute(() -> {
try {
this.sendResetPasswordEmail(passwordResetLink, email);
} catch (ThingsboardException e) {
} catch (Exception e) {
log.error("Error occurred: {} ", e.getMessage());
}
});

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

@ -225,7 +225,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
@Override
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<>();
if (!isAttributes && !isTimeseries) {
replyWithEmptyResponse(response);
@ -253,7 +253,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
if (isAttributes) {
Map<EntityType, List<EntityId>> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType));
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 -> {
if (CollectionUtils.isEmpty(lists)) {
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);
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, 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> 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.stereotype.Service;
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.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
@ -83,6 +84,15 @@ public class BaseAttributesService implements AttributesService {
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
public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) {
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);
}
@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
public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) {
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 " +
"AND entity_id in :entityIds ORDER BY attribute_key", nativeQuery = true)
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()));
}
@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
public ListenableFuture<String> save(TenantId tenantId, EntityId entityId, String attributeType, AttributeKvEntry attribute) {
AttributeKvEntity entity = new AttributeKvEntity();

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

File diff suppressed because one or more lines are too long

3
ui-ngx/angular.json

@ -158,7 +158,8 @@
"ace-builds",
"diff-match-patch",
"tv4",
"@messageformat/parser"
"@messageformat/parser",
"sorted-btree"
]
},
"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 { 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 { EntityService } from '@core/http/entity.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 { map, mergeMap } from 'rxjs/operators';
import {
AlarmFilter,
AlarmFilterConfig,
createDefaultEntityDataPageLink,
Filter,
FilterInfo,
@ -53,10 +58,10 @@ export class AliasController implements IAliasController {
filters: Filters;
userFilters: Filters;
resolvedAliases: {[aliasId: string]: AliasInfo} = {};
resolvedAliasesObservable: {[aliasId: string]: Observable<AliasInfo>} = {};
resolvedAliases: { [aliasId: string]: AliasInfo } = {};
resolvedAliasesObservable: { [aliasId: string]: Observable<AliasInfo> } = {};
resolvedAliasesToStateEntities: {[aliasId: string]: StateEntityInfo} = {};
resolvedAliasesToStateEntities: { [aliasId: string]: StateEntityInfo } = {};
constructor(private utils: UtilsService,
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> {
const newDatasource = deepClone(datasource);
if (newDatasource.type === DatasourceType.entity

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

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

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

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

10
ui-ngx/src/app/core/http/asset-profile.service.ts

@ -21,6 +21,8 @@ import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils';
import { Observable } from 'rxjs';
import { PageData } from '@shared/models/page/page-data';
import { AssetProfile, AssetProfileInfo } from '@shared/models/asset.models';
import { EntityInfoData } from '@shared/models/entity.models';
import { isDefinedAndNotNull } from '@core/utils';
@Injectable({
providedIn: 'root'
@ -68,4 +70,12 @@ export class AssetProfileService {
return this.http.get<PageData<AssetProfileInfo>>(`/api/assetProfileInfos${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getAssetProfileNames(activeOnly: boolean = false, config?: RequestConfig): Observable<Array<EntityInfoData>> {
let url = '/api/assetProfile/names';
if (isDefinedAndNotNull(activeOnly)) {
url += `?activeOnly=${activeOnly}`;
}
return this.http.get<Array<EntityInfoData>>(url, defaultHttpOptionsFromConfig(config));
}
}

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

@ -31,6 +31,7 @@ import { SortOrder } from '@shared/models/page/sort-order';
import { OtaPackageService } from '@core/http/ota-package.service';
import { map, mergeMap, tap } from 'rxjs/operators';
import { Lwm2mSecurityType } from '@shared/models/lwm2m-security-config.models';
import { EntityInfoData } from '@shared/models/entity.models';
@Injectable({
providedIn: 'root'
@ -169,4 +170,12 @@ export class DeviceProfileService {
return this.http.get<Array<string>>(url, defaultHttpOptionsFromConfig(config));
}
public getDeviceProfileNames(activeOnly: boolean = false, config?: RequestConfig): Observable<Array<EntityInfoData>> {
let url = '/api/deviceProfile/names';
if (isDefinedAndNotNull(activeOnly)) {
url += `?activeOnly=${activeOnly}`;
}
return this.http.get<Array<EntityInfoData>>(url, defaultHttpOptionsFromConfig(config));
}
}

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> {
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> {
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> {

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

@ -501,9 +501,13 @@ export class EntityService {
}
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>(
`/api/entitiesQuery/find/keys?attributes=${attributes}&timeseries=${timeseries}`,
url,
query, defaultHttpOptionsFromConfig(config));
}
@ -831,7 +835,14 @@ export class EntityService {
}
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) {
return of([]);
}
@ -842,7 +853,7 @@ export class EntityService {
pageLink: createDefaultEntityDataPageLink(100),
};
entitiesKeysByQuery$ = this.findEntityKeysByQuery(dataQuery, types.includes(DataKeyType.attribute),
types.includes(DataKeyType.timeseries), config);
types.includes(DataKeyType.timeseries), scope, config);
} else {
entitiesKeysByQuery$ = of({
attribute: [],
@ -1519,4 +1530,31 @@ export class EntityService {
}
return entityObservable;
}
public getEntitySubtypesObservable(entityType: EntityType): Observable<Array<string>> {
let observable: Observable<Array<string>>;
switch (entityType) {
case EntityType.ASSET:
observable = this.assetProfileService.getAssetProfileNames(false, {ignoreLoading: true}).pipe(
map(subTypes => subTypes.map(subType => subType.name))
);
break;
case EntityType.DEVICE:
observable = this.deviceProfileService.getDeviceProfileNames(false,{ignoreLoading: true}).pipe(
map(subTypes => subTypes.map(subType => subType.name))
);
break;
case EntityType.EDGE:
observable = this.edgeService.getEdgeTypes({ignoreLoading: true}).pipe(
map(subTypes => subTypes.map(subType => subType.type))
);
break;
case EntityType.ENTITY_VIEW:
observable = this.entityViewService.getEntityViewTypes({ignoreLoading: true}).pipe(
map(subTypes => subTypes.map(subType => subType.type))
);
break;
}
return observable;
}
}

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 { parseHttpErrorMessage } from '@core/utils';
let tmpHeaders = {};
const tmpHeaders = {};
@Injectable()
export class GlobalHttpInterceptor implements HttpInterceptor {
@ -85,7 +85,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
if (newReq) {
return this.handleRequest(newReq, next);
} 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);
this.showError(errorMessageWithTimeout.message, errorMessageWithTimeout.timeout);
}
return throwError(errorResponse);
return throwError(() => errorResponse);
}
private retryRequest(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const thisTimeout = 1000 + Math.random() * 3000;
return of(null).pipe(
delay(thisTimeout),
mergeMap(() => {
return this.jwtIntercept(req, next);
}
mergeMap(() => this.jwtIntercept(req, next)
));
}
private refreshTokenAndRetry(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return this.authService.refreshJwtToken().pipe(switchMap(() => {
return this.jwtIntercept(req, next);
}),
catchError((err: Error) => {
this.authService.logout(true, true);
const message = err ? err.message : 'Unauthorized!';
return this.handleResponseError(req, next, new HttpErrorResponse({error: {message, timeout: 200}, status: 401}));
}));
return this.authService.refreshJwtToken().pipe(
catchError((err: Error) => {
this.authService.logout(true, true);
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> {
const jwtToken = AuthService.getJwtToken();
if (jwtToken) {
tmpHeaders[this.AUTH_HEADER_NAME] = `${this.AUTH_SCHEME}${jwtToken}`;
req = req.clone({
setHeaders: (tmpHeaders = {},
tmpHeaders[this.AUTH_HEADER_NAME] = '' + this.AUTH_SCHEME + jwtToken,
tmpHeaders)
setHeaders: tmpHeaders
});
return req;
} 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) {
if (url.startsWith(this.internalUrlPrefixes[index])) {
return true;
@ -183,7 +180,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
return false;
}
private isTokenBasedAuthEntryPoint(url): boolean {
private isTokenBasedAuthEntryPoint(url: string): boolean {
return url.startsWith('/api/') &&
!url.startsWith(Constants.entryPoints.login) &&
!url.startsWith(Constants.entryPoints.tokenRefresh) &&

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

@ -29,20 +29,15 @@ import {
GridSettings,
WidgetLayout
} from '@shared/models/dashboard.models';
import {
deepClone,
isDefined,
isDefinedAndNotNull,
isEmptyStr,
isNotEmptyStr,
isString,
isUndefined
} from '@core/utils';
import { deepClone, isDefined, isDefinedAndNotNull, isNotEmptyStr, isString, isUndefined } from '@core/utils';
import {
Datasource,
datasourcesHasOnlyComparisonAggregation,
DatasourceType,
defaultLegendConfig, isValidWidgetFullFqn,
defaultLegendConfig,
isValidWidgetFullFqn,
TargetDevice,
TargetDeviceType,
Widget,
WidgetConfig,
WidgetConfigMode,
@ -124,7 +119,7 @@ export class DashboardUtilsService {
}
}
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)) {
const widget = dashboard.configuration.widgets[widgetId];
const datasources = widget.type === widgetType.alarm ? [widget.config.alarmSource] : widget.config.datasources;
@ -139,14 +134,14 @@ export class DashboardUtilsService {
aliasDatasources.push(datasource);
}
});
if (widget.config.targetDeviceAliasIds && widget.config.targetDeviceAliasIds.length) {
const aliasId = widget.config.targetDeviceAliasIds[0];
let targetDeviceAliasIdsList = targetDevicesByAliasId[aliasId];
if (!targetDeviceAliasIdsList) {
targetDeviceAliasIdsList = [];
targetDevicesByAliasId[aliasId] = targetDeviceAliasIdsList;
if (widget.config.targetDevice?.type === TargetDeviceType.entity && widget.config.targetDevice.entityAliasId) {
const aliasId = widget.config.targetDevice.entityAliasId;
let targetDevicesList = targetDevicesByAliasId[aliasId];
if (!targetDevicesList) {
targetDevicesList = [];
targetDevicesByAliasId[aliasId] = targetDevicesList;
}
targetDeviceAliasIdsList.push(widget.config.targetDeviceAliasIds);
targetDevicesList.push(widget.config.targetDevice);
}
}
@ -281,21 +276,23 @@ export class DashboardUtilsService {
if (!widgetConfig) {
widgetConfig = {};
}
if (!widgetConfig.datasources) {
widgetConfig.datasources = [];
}
widgetConfig.datasources.forEach((datasource) => {
if (datasource.deviceAliasId) {
datasource.type = DatasourceType.entity;
datasource.entityAliasId = datasource.deviceAliasId;
delete datasource.deviceAliasId;
}
});
widgetConfig.datasources = this.validateAndUpdateDatasources(widgetConfig.datasources);
if (type === widgetType.latest) {
const onlyHistoryTimewindow = datasourcesHasOnlyComparisonAggregation(widgetConfig.datasources);
widgetConfig.timewindow = initModelFromDefaultTimewindow(widgetConfig.timewindow, true, onlyHistoryTimewindow, this.timeService);
}
if (type === widgetType.alarm) {
} else if (type === widgetType.rpc) {
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) {
widgetConfig.alarmFilterConfig = {};
const alarmFilterConfig = widgetConfig.alarmFilterConfig;
@ -335,6 +332,35 @@ export class DashboardUtilsService {
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 {
return {
widgets: {},
@ -689,7 +715,7 @@ export class DashboardUtilsService {
private validateAndUpdateEntityAliases(configuration: DashboardConfiguration,
datasourcesByAliasId: {[aliasId: string]: Array<Datasource>},
targetDevicesByAliasId: {[aliasId: string]: Array<Array<string>>}): DashboardConfiguration {
targetDevicesByAliasId: {[aliasId: string]: Array<TargetDevice>}): DashboardConfiguration {
let entityAlias: EntityAlias;
if (isUndefined(configuration.entityAliases)) {
configuration.entityAliases = {};
@ -719,7 +745,7 @@ export class DashboardUtilsService {
private validateAndUpdateDeviceAlias(aliasId: string,
deviceAlias: any,
datasourcesByAliasId: {[aliasId: string]: Array<Datasource>},
targetDevicesByAliasId: {[aliasId: string]: Array<Array<string>>}): EntityAlias {
targetDevicesByAliasId: {[aliasId: string]: Array<TargetDevice>}): EntityAlias {
aliasId = this.validateAliasId(aliasId, datasourcesByAliasId, targetDevicesByAliasId);
const alias = deviceAlias.alias;
const entityAlias: EntityAlias = {
@ -748,7 +774,7 @@ export class DashboardUtilsService {
private validateAndUpdateEntityAlias(aliasId: string, entityAlias: EntityAlias,
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);
if (!entityAlias.filter) {
entityAlias.filter = {
@ -810,7 +836,7 @@ export class DashboardUtilsService {
private validateAliasId(aliasId: string,
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) {
const newAliasId = this.utils.guid();
const aliasDatasources = datasourcesByAliasId[aliasId];
@ -821,11 +847,11 @@ export class DashboardUtilsService {
}
);
}
const targetDeviceAliasIdsList = targetDevicesByAliasId[aliasId];
if (targetDeviceAliasIdsList) {
targetDeviceAliasIdsList.forEach(
(targetDeviceAliasIds) => {
targetDeviceAliasIds[0] = newAliasId;
const targetDevicesList = targetDevicesByAliasId[aliasId];
if (targetDevicesList) {
targetDevicesList.forEach(
(targetDevice) => {
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 {
Datasource,
DatasourceType,
TargetDeviceType,
Widget,
WidgetPosition,
WidgetSize,
@ -87,7 +88,7 @@ export class ItemBufferService {
public prepareWidgetItem(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget): WidgetItem {
const aliasesInfo: AliasesInfo = {
datasourceAliases: {},
targetDeviceAliases: {}
targetDeviceAlias: null
};
const filtersInfo: FiltersInfo = {
datasourceFilters: {}
@ -111,15 +112,11 @@ export class ItemBufferService {
}
}
}
if (widget.config.targetDeviceAliasIds) {
for (let i = 0; i < widget.config.targetDeviceAliasIds.length; i++) {
const targetDeviceAliasId = widget.config.targetDeviceAliasIds[i];
if (targetDeviceAliasId) {
entityAlias = dashboard.configuration.entityAliases[targetDeviceAliasId];
if (entityAlias) {
aliasesInfo.targetDeviceAliases[i] = this.prepareAliasInfo(entityAlias);
}
}
if (widget.config.targetDevice?.type === TargetDeviceType.entity && widget.config.targetDevice.entityAliasId) {
const targetDeviceAliasId = widget.config.targetDevice.entityAliasId;
entityAlias = dashboard.configuration.entityAliases[targetDeviceAliasId];
if (entityAlias) {
aliasesInfo.targetDeviceAlias = this.prepareAliasInfo(entityAlias);
}
}
}
@ -456,11 +453,15 @@ export class ItemBufferService {
widget.config.datasources[datasourceIndex].entityAliasId = newAliasId;
}
}
for (const targetDeviceAliasIndexStr of Object.keys(aliasesInfo.targetDeviceAliases)) {
const targetDeviceAliasIndex = Number(targetDeviceAliasIndexStr);
aliasInfo = aliasesInfo.targetDeviceAliases[targetDeviceAliasIndex];
if (aliasesInfo.targetDeviceAlias) {
aliasInfo = aliasesInfo.targetDeviceAlias;
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;
}

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

@ -294,32 +294,6 @@ export class UtilsService {
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) {
const colorIndex = index % materialColors.length;
return materialColors[colorIndex].value;

30
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 { TranslateService } from '@ngx-translate/core';
import { serverErrorCodesTranslations } from '@shared/models/constants';
import { SubscriptionEntityInfo } from '@core/api/widget-api.models';
const varsRegex = /\${([^}]*)}/g;
@ -426,6 +427,33 @@ export const createLabelFromDatasource = (datasource: Datasource, pattern: strin
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 function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number): FormattedData[] {
@ -711,7 +739,7 @@ export function randomAlphanumeric(length: number): string {
}
export function getEntityDetailsPageURL(id: string, entityType: EntityType): string {
return `${baseDetailsPageByEntityType.get(entityType)}/${id}`;
return baseDetailsPageByEntityType.has(entityType) ? `${baseDetailsPageByEntityType.get(entityType)}/${id}` : '';
}
export function parseHttpErrorMessage(errorResponse: HttpErrorResponse,

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

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

@ -15,7 +15,7 @@
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">
<h2>{{ (isTelemetry ? 'attribute.add-telemetry' : 'attribute.add') | translate }}</h2>
<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) => {
dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard);
return this.selectTargetState(dashboard).pipe(
mergeMap((targetState) => {
return forkJoin([of(dashboard), of(targetState), this.selectTargetLayout(dashboard, targetState)]);
})
mergeMap((targetState) =>
forkJoin([of(dashboard), of(targetState), this.selectTargetLayout(dashboard, targetState)]))
);
})
).subscribe((res) => {
@ -177,7 +176,7 @@ export class AddWidgetToDashboardDialogComponent extends
private addWidgetToDashboard(dashboard: Dashboard, targetState: string, targetLayout: DashboardLayoutId) {
const aliasesInfo: AliasesInfo = {
datasourceAliases: {},
targetDeviceAliases: {}
targetDeviceAlias: null
};
aliasesInfo.datasourceAliases[0] = {
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"
[formGroup]="attributeFormGroup" (ngSubmit)="update()" style="padding: 5px;">
[formGroup]="attributeFormGroup" (ngSubmit)="update()" style="padding: 5px; width: 375px;">
<fieldset [disabled]="isLoading$ | async">
<tb-value-input
formControlName="value"

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

@ -21,7 +21,7 @@
</mat-spinner>
</div>
<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"
[style.backgroundImage]="backgroundImage$ | async"
[ngStyle]="dashboardStyle">

4
ui-ngx/src/app/modules/home/components/entity/entities-table.component.html

@ -165,8 +165,8 @@
[matTooltip]="cellTooltip(entity, column, row)"
matTooltipPosition="above"
[ngStyle]="cellStyle(entity, column, row)">
<ng-container *ngIf="column.type == 'link'; else defaultContent">
<a [routerLink]="column.entityURL(entity)" [innerHTML]="cellContent(entity, column, row)" (click)="$event.stopPropagation();"></a>
<ng-container *ngIf="column.type == 'link' && column.entityURL(entity) as detailsPageURL; else defaultContent">
<a [routerLink]="detailsPageURL" [innerHTML]="cellContent(entity, column, row)" (click)="$event.stopPropagation();"></a>
</ng-container>
<ng-template #defaultContent>
<span [innerHTML]="cellContent(entity, column, row)"></span>

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

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

@ -97,6 +97,7 @@
#dashboardStateInput
formControlName="targetDashboardStateId"
[required]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState"
(focusin)="onFocus()"
[matAutocomplete]="targetDashboardStateAutocomplete">
<button *ngIf="actionTypeFormGroup.get('targetDashboardStateId').value"
type="button"

65
ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts

@ -28,7 +28,7 @@ import {
ValidatorFn,
Validators
} from '@angular/forms';
import { Observable, of, Subscription } from 'rxjs';
import { Observable, of, Subject, Subscription } from 'rxjs';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import {
@ -43,7 +43,7 @@ import {
WidgetActionType,
widgetActionTypeTranslationMap
} from '@shared/models/widget.models';
import { map, mergeMap, startWith, tap } from 'rxjs/operators';
import { map, mergeMap, startWith, takeUntil, tap } from 'rxjs/operators';
import { DashboardService } from '@core/http/dashboard.service';
import { Dashboard } from '@shared/models/dashboard.models';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
@ -88,6 +88,10 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
@ViewChild('mobileActionEditor', {static: false}) mobileActionEditor: MobileActionEditorComponent;
private destroy$ = new Subject<void>();
private dashboard: Dashboard;
widgetActionFormGroup: UntypedFormGroup;
actionTypeFormGroup: UntypedFormGroup;
actionTypeFormGroupSubscriptions: Subscription[] = [];
@ -156,18 +160,30 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
this.fb.control(this.action.type, [Validators.required]));
this.updateShowWidgetActionForm();
this.updateActionTypeFormGroup(this.action.type, this.action);
this.widgetActionFormGroup.get('type').valueChanges.subscribe((type: WidgetActionType) => {
this.widgetActionFormGroup.get('type').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((type: WidgetActionType) => {
this.updateActionTypeFormGroup(type);
});
this.widgetActionFormGroup.get('actionSourceId').valueChanges.subscribe(() => {
this.widgetActionFormGroup.get('actionSourceId').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(() => {
this.widgetActionFormGroup.get('name').updateValueAndValidity();
this.updateShowWidgetActionForm();
});
this.widgetActionFormGroup.get('useShowWidgetActionFunction').valueChanges.subscribe(() => {
this.widgetActionFormGroup.get('useShowWidgetActionFunction').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(() => {
this.updateShowWidgetActionForm();
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
super.ngOnDestroy();
}
displayShowWidgetActionForm(): boolean {
return !!this.data.actionsData.actionSources[this.widgetActionFormGroup.get('actionSourceId').value]?.hasShowCondition;
}
@ -225,7 +241,7 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
this.fb.control(action ? action.targetDashboardId : null,
[Validators.required])
);
this.setupSelectedDashboardStateIds(action ? action.targetDashboardId : null);
this.setupSelectedDashboardStateIds();
} else {
if (type === WidgetActionType.openDashboardState) {
const displayType = this.getStateDisplayType(action);
@ -235,8 +251,10 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
);
this.updateStateDisplayTypeFormGroup(displayType, action);
this.actionTypeFormGroupSubscriptions.push(
this.actionTypeFormGroup.get('stateDisplayType').valueChanges.subscribe((displayTypeValue: stateDisplayType) => {
this.updateStateDisplayTypeFormGroup(displayTypeValue);
this.actionTypeFormGroup.get('stateDisplayType').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((displayTypeValue: stateDisplayType) => {
this.updateStateDisplayTypeFormGroup(displayTypeValue);
})
);
}
@ -339,25 +357,35 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
return res;
}
private setupSelectedDashboardStateIds(targetDashboardId?: string) {
private setupSelectedDashboardStateIds() {
this.selectedDashboardStateIds =
this.actionTypeFormGroup.get('targetDashboardId').valueChanges.pipe(
// startWith<string>(targetDashboardId),
tap(() => {
tap((dashboardId) => {
if (!dashboardId) {
this.actionTypeFormGroup.get('targetDashboardStateId')
.patchValue('', {emitEvent: true});
}
this.targetDashboardStateSearchText = '';
}),
mergeMap((dashboardId) => {
if (dashboardId) {
return this.dashboardService.getDashboard(dashboardId);
if (this.dashboard?.id.id === dashboardId) {
return of(this.dashboard);
} else {
return this.dashboardService.getDashboard(dashboardId);
}
} else {
return of(null);
}
}),
map((dashboard: Dashboard) => {
if (dashboard) {
dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard);
const states = dashboard.configuration.states;
return Object.keys(states);
if (this.dashboard?.id.id !== dashboard.id.id) {
this.dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard);
}
return Object.keys(this.dashboard.configuration.states);
} else {
return [];
}
@ -371,7 +399,8 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
.pipe(
startWith(''),
map(value => value ? value : ''),
mergeMap(name => this.fetchDashboardStates(name) )
mergeMap(name => this.fetchDashboardStates(name)),
takeUntil(this.destroy$)
);
}
@ -461,6 +490,10 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
}
}
onFocus(): void {
this.actionTypeFormGroup.get('targetDashboardId').updateValueAndValidity({onlySelf: true, emitEvent: true});
}
cancel(): void {
this.dialogRef.close(null);
}

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

@ -91,6 +91,9 @@ import {
import {
BarChartWithLabelsBasicConfigComponent
} 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({
declarations: [
@ -119,7 +122,8 @@ import {
LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent,
RangeChartBasicConfigComponent,
BarChartWithLabelsBasicConfigComponent
BarChartWithLabelsBasicConfigComponent,
SingSwitchBasicConfigComponent
],
imports: [
CommonModule,
@ -152,7 +156,8 @@ import {
LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent,
RangeChartBasicConfigComponent,
BarChartWithLabelsBasicConfigComponent
BarChartWithLabelsBasicConfigComponent,
SingSwitchBasicConfigComponent
]
})
export class BasicWidgetConfigModule {
@ -179,5 +184,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type<IBasicWidgetCo
'tb-liquid-level-card-basic-config': LiquidLevelCardBasicConfigComponent,
'tb-doughnut-basic-config': DoughnutBasicConfigComponent,
'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
};

2
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts

@ -149,7 +149,7 @@ export class DataKeysPanelComponent implements ControlValueAccessor, OnInit, OnC
get noKeys(): boolean {
let keys: DataKey[] = this.keysListFormGroup.get('keys').value;
if (this.hasAdditionalLatestDataKeys) {
keys = keys.filter(k => !(k as any).latest);
keys = keys.filter(k => !(k as any)?.latest);
}
return keys.length === 0;
}

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 { TimewindowStyleComponent } from '@home/components/widget/config/timewindow-style.component';
import { TimewindowStylePanelComponent } from '@home/components/widget/config/timewindow-style-panel.component';
import { TargetDeviceComponent } from '@home/components/widget/config/target-device.component';
@NgModule({
declarations:
@ -43,6 +44,7 @@ import { TimewindowStylePanelComponent } from '@home/components/widget/config/ti
DataKeyConfigComponent,
DatasourceComponent,
DatasourcesComponent,
TargetDeviceComponent,
EntityAliasSelectComponent,
FilterSelectComponent,
TimewindowStyleComponent,
@ -64,6 +66,7 @@ import { TimewindowStylePanelComponent } from '@home/components/widget/config/ti
DataKeyConfigComponent,
DatasourceComponent,
DatasourcesComponent,
TargetDeviceComponent,
EntityAliasSelectComponent,
FilterSelectComponent,
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 { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { isDefinedAndNotNull } from '@core/utils';
import { IAliasController } from '@core/api/widget-api.models';
export type WidgetConfigCallbacks = DatasourceCallbacks & WidgetActionCallbacks;
@ -58,6 +59,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement
return this.widgetConfigValue;
}
get aliasController(): IAliasController {
return this.widgetConfigComponent.aliasController;
}
widgetConfigChangedEmitter = new EventEmitter<WidgetConfigComponentData>();
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;
rpcEnabled: boolean;
rpcErrorText: string;
rpcRejection: HttpErrorResponse;
rpcRejection: HttpErrorResponse | Error;
[key: string]: any;

2
ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.scss

@ -34,6 +34,8 @@
padding-right: 38px;
overflow: scroll;
padding-bottom: 4px;
min-height: 42px;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;

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 {
ColorRangeSettingsComponent, ColorRangeSettingsComponentService
} 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({
declarations: [
@ -71,7 +86,12 @@ import {
CountWidgetSettingsComponent,
ColorRangeListComponent,
ColorRangePanelComponent,
ColorRangeSettingsComponent
ColorRangeSettingsComponent,
RpcInitialStateSettingsComponent,
RpcInitialStateSettingsPanelComponent,
DeviceKeyAutocompleteComponent,
RpcUpdateStateSettingsComponent,
RpcUpdateStateSettingsPanelComponent
],
imports: [
CommonModule,
@ -96,7 +116,12 @@ import {
CountWidgetSettingsComponent,
ColorRangeListComponent,
ColorRangePanelComponent,
ColorRangeSettingsComponent
ColorRangeSettingsComponent,
RpcInitialStateSettingsComponent,
RpcInitialStateSettingsPanelComponent,
DeviceKeyAutocompleteComponent,
RpcUpdateStateSettingsComponent,
RpcUpdateStateSettingsPanelComponent
],
providers: [
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.
-->
<mat-form-field fxFlex class="mat-block" [formGroup]="deviceKeyFormGroup">
<mat-label>{{ (keyType === dataKeyType.attribute
<mat-form-field fxFlex [appearance]="inlineField ? 'outline': 'fill'"
[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
: timeseriesLabel) | translate }}</mat-label>
<input [required]="required" matInput
@ -31,6 +36,15 @@
(click)="clearKey()">
<mat-icon class="material-icons">close</mat-icon>
</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
class="tb-autocomplete"
#keyAutocomplete="matAutocomplete">
@ -38,4 +52,7 @@
<span [innerHTML]="key | highlight:keySearchText"></span>
</mat-option>
</mat-autocomplete>
<mat-error *ngIf="!inlineField && requiredText && deviceKeyFormGroup.get('key').hasError('required')">
{{ requiredText | translate }}
</mat-error>
</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 { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { Observable, of } from 'rxjs';
import { IAliasController } from '@core/api/widget-api.models';
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 { coerceBooleanProperty } from '@angular/cdk/coercion';
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({
selector: 'tb-device-key-autocomplete',
@ -58,25 +60,30 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
aliasController: IAliasController;
@Input()
targetDeviceAliasId: string;
targetDevice: TargetDevice;
@Input()
keyType: DataKeyType;
@Input()
attributeScope: AttributeScope;
@Input()
attributeLabel = 'widgets.rpc.attribute-value-key';
@Input()
timeseriesLabel = 'widgets.rpc.timeseries-value-key';
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
requiredText: string;
@Input()
@coerceBoolean()
required: boolean;
@Input()
@coerceBoolean()
inlineField: boolean;
dataKeyType = DataKeyType;
@ -93,7 +100,6 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
private keysFetchObservable$: Observable<Array<string>> = null;
constructor(protected store: Store<AppState>,
private translate: TranslateService,
private entityService: EntityService,
private fb: UntypedFormBuilder) {
super(store);
@ -118,7 +124,7 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'targetDeviceAliasId' || propName === 'keyType') {
if (propName === 'targetDevice' || propName === 'keyType' || propName === 'attributeScope') {
this.clearKeysCache();
}
}
@ -186,9 +192,9 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
private getKeys() {
if (this.keysFetchObservable$ === null) {
let fetchObservable: Observable<Array<DataKey>>;
if (this.targetDeviceAliasId) {
if (targetDeviceValid(this.targetDevice)) {
const dataKeyTypes = [this.keyType];
fetchObservable = this.fetchEntityKeys(this.targetDeviceAliasId, dataKeyTypes);
fetchObservable = this.fetchEntityKeys(this.targetDevice, dataKeyTypes);
} else {
fetchObservable = of([]);
}
@ -201,17 +207,25 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI
return this.keysFetchObservable$;
}
private fetchEntityKeys(entityAliasId: string, dataKeyTypes: Array<DataKeyType>): Observable<Array<DataKey>> {
return this.aliasController.getAliasInfo(entityAliasId).pipe(
mergeMap((aliasInfo) => {
return this.entityService.getEntityKeysByEntityFilter(
aliasInfo.entityFilter,
private fetchEntityKeys(targetDevice: TargetDevice, dataKeyTypes: Array<DataKeyType>): Observable<Array<DataKey>> {
let entityFilter$: Observable<EntityFilter>;
if (targetDevice.type === TargetDeviceType.device) {
entityFilter$ = of(singleEntityFilterFromDeviceId(targetDevice.deviceId));
} else {
entityFilter$ = this.aliasController.getAliasInfo(targetDevice.entityAliasId).pipe(
map(aliasInfo => aliasInfo.entityFilter)
);
}
return entityFilter$.pipe(
mergeMap((entityFilter) =>
this.entityService.getEntityKeysByEntityFilterAndScope(
entityFilter,
dataKeyTypes, [EntityType.DEVICE],
this.attributeScope,
{ignoreLoading: true, ignoreErrors: true}
).pipe(
catchError(() => of([]))
);
}),
)),
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
[fxShow]="!ledIndicatorWidgetSettingsForm.get('performCheckStatus').value"
[aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId"
[targetDevice]="targetDevice"
[keyType]="ledIndicatorWidgetSettingsForm.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries"
attributeLabel="widgets.rpc.led-status-value-attribute"
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 { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
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';
@ -31,12 +31,8 @@ export class LedIndicatorWidgetSettingsComponent extends WidgetSettingsComponent
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
get targetDeviceAliasId(): string {
const aliasIds = this.widget?.config?.targetDeviceAliasIds;
if (aliasIds && aliasIds.length) {
return aliasIds[0];
}
return null;
get targetDevice(): TargetDevice {
return this.widget?.config?.targetDevice;
}
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>
</fieldset>
<tb-switch-rpc-settings [aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId"
[targetDevice]="targetDevice"
formControlName="switchRpcSettings">
</tb-switch-rpc-settings>
</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 { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -36,12 +36,8 @@ export class RoundSwitchWidgetSettingsComponent extends WidgetSettingsComponent
super(store);
}
get targetDeviceAliasId(): string {
const aliasIds = this.widget?.config?.targetDeviceAliasIds;
if (aliasIds && aliasIds.length) {
return aliasIds[0];
}
return null;
get targetDevice(): TargetDevice {
return this.widget?.config?.targetDevice;
}
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>
</fieldset>
<tb-switch-rpc-settings [aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId"
[targetDevice]="targetDevice"
formControlName="switchRpcSettings">
</tb-switch-rpc-settings>
</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 { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -36,12 +36,8 @@ export class SlideToggleWidgetSettingsComponent extends WidgetSettingsComponent
super(store);
}
get targetDeviceAliasId(): string {
const aliasIds = this.widget?.config?.targetDeviceAliasIds;
if (aliasIds && aliasIds.length) {
return aliasIds[0];
}
return null;
get targetDevice(): TargetDevice {
return this.widget?.config?.targetDevice;
}
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>
</fieldset>
<tb-switch-rpc-settings [aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId"
[targetDevice]="targetDevice"
formControlName="switchRpcSettings">
</tb-switch-rpc-settings>
</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 { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -36,12 +36,8 @@ export class SwitchControlWidgetSettingsComponent extends WidgetSettingsComponen
super(store);
}
get targetDeviceAliasId(): string {
const aliasIds = this.widget?.config?.targetDeviceAliasIds;
if (aliasIds && aliasIds.length) {
return aliasIds[0];
}
return null;
get targetDevice(): TargetDevice {
return this.widget?.config?.targetDevice;
}
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' ||
switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'timeseries'"
[aliasController]="aliasController"
[targetDeviceAliasId]="targetDeviceAliasId"
[targetDevice]="targetDevice"
[keyType]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries"
required
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 {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
Validator,
Validators
} from '@angular/forms';
@ -33,6 +33,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { WidgetService } from '@core/http/widget.service';
import { IAliasController } from '@core/api/widget-api.models';
import { EntityService } from '@core/http/entity.service';
import { TargetDevice } from '@shared/models/widget.models';
export declare type RpcRetrieveValueMethod = 'none' | 'rpc' | 'attribute' | 'timeseries';
@ -49,8 +50,7 @@ export interface SwitchRpcSettings {
persistentPollingInterval: number;
}
export function switchRpcDefaultSettings(): SwitchRpcSettings {
return {
export const switchRpcDefaultSettings = (): SwitchRpcSettings => ({
initialValue: false,
retrieveValueMethod: 'rpc',
valueKey: 'value',
@ -61,8 +61,7 @@ export function switchRpcDefaultSettings(): SwitchRpcSettings {
requestTimeout: 500,
requestPersistent: false,
persistentPollingInterval: 5000
};
}
});
@Component({
selector: 'tb-switch-rpc-settings',
@ -92,7 +91,7 @@ export class SwitchRpcSettingsComponent extends PageComponent implements OnInit,
aliasController: IAliasController;
@Input()
targetDeviceAliasId: string;
targetDevice: TargetDevice;
dataKeyType = DataKeyType;
@ -185,8 +184,7 @@ export class SwitchRpcSettingsComponent extends PageComponent implements OnInit,
}
private updateModel() {
const value: SwitchRpcSettings = this.switchRpcSettingsFormGroup.value;
this.modelValue = value;
this.modelValue = this.switchRpcSettingsFormGroup.value;
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 {
SendRpcWidgetSettingsComponent
} 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 {
LedIndicatorWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/led-indicator-widget-settings.component';
@ -311,6 +308,9 @@ import {
import {
BarChartWithLabelsWidgetSettingsComponent
} 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({
declarations: [
@ -348,7 +348,6 @@ import {
FlotPieKeySettingsComponent,
ChartWidgetSettingsComponent,
DoughnutChartWidgetSettingsComponent,
DeviceKeyAutocompleteComponent,
SwitchRpcSettingsComponent,
RoundSwitchWidgetSettingsComponent,
SwitchControlWidgetSettingsComponent,
@ -424,7 +423,8 @@ import {
LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent,
RangeChartWidgetSettingsComponent,
BarChartWithLabelsWidgetSettingsComponent
BarChartWithLabelsWidgetSettingsComponent,
SingleSwitchWidgetSettingsComponent
],
imports: [
CommonModule,
@ -467,7 +467,6 @@ import {
FlotPieKeySettingsComponent,
ChartWidgetSettingsComponent,
DoughnutChartWidgetSettingsComponent,
DeviceKeyAutocompleteComponent,
SwitchRpcSettingsComponent,
RoundSwitchWidgetSettingsComponent,
SwitchControlWidgetSettingsComponent,
@ -543,7 +542,8 @@ import {
LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent,
RangeChartWidgetSettingsComponent,
BarChartWithLabelsWidgetSettingsComponent
BarChartWithLabelsWidgetSettingsComponent,
SingleSwitchWidgetSettingsComponent
]
})
export class WidgetSettingsModule {
@ -628,5 +628,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type<IWidgetSettingsCo
'tb-liquid-level-card-widget-settings': LiquidLevelCardWidgetSettingsComponent,
'tb-doughnut-widget-settings': DoughnutWidgetSettingsComponent,
'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
};

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

@ -407,9 +407,10 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
}
}
if (this.sources.length) {
this.prepareDisplayedColumn();
this.sources[this.sourceIndex].displayedColumns =
this.displayedColumns[this.sourceIndex].filter(value => value.display).map(value => value.def);
this.sources.forEach((source, index) => {
this.prepareDisplayedColumn(index);
source.displayedColumns = this.displayedColumns[index].filter(value => value.display).map(value => value.def);
});
}
this.updateActiveEntityInfo();
}
@ -438,8 +439,6 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
const source = this.sources[this.sourceIndex];
this.prepareDisplayedColumn();
const providers: StaticProvider[] = [
{
provide: DISPLAY_COLUMNS_PANEL_DATA,
@ -472,11 +471,11 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
}
}
private prepareDisplayedColumn() {
if (!this.displayedColumns[this.sourceIndex]) {
this.displayedColumns[this.sourceIndex] = this.sources[this.sourceIndex].displayedColumns.map(value => {
private prepareDisplayedColumn(index: number) {
if (!this.displayedColumns[index]) {
this.displayedColumns[index] = this.sources[index].displayedColumns.map(value => {
let title = '';
const header = this.sources[this.sourceIndex].header.find(column => column.index.toString() === value);
const header = this.sources[index].header.find(column => column.index.toString() === value);
if (value === '0') {
title = 'Timestamp';
} else if (value === 'actions') {

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)) {
result.typeParameters.defaultLatestDataKeysFunction = null;
}
if (isUndefined(result.typeParameters.displayRpcMessageToast)) {
result.typeParameters.displayRpcMessageToast = true;
}
if (isFunction(widgetTypeInstance.actionSources)) {
result.actionSources = widgetTypeInstance.actionSources();
} else {

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

@ -79,6 +79,7 @@ import {
import {
GatewayServiceRPCConnectorTemplateDialogComponent
} from '@home/components/widget/lib/gateway/gateway-service-rpc-connector-template-dialog';
import { SingleSwitchWidgetComponent } from '@home/components/widget/lib/rpc/single-switch-widget.component';
@NgModule({
declarations:
@ -124,7 +125,8 @@ import {
LiquidLevelWidgetComponent,
DoughnutWidgetComponent,
RangeChartWidgetComponent,
BarChartWithLabelsWidgetComponent
BarChartWithLabelsWidgetComponent,
SingleSwitchWidgetComponent
],
imports: [
CommonModule,
@ -174,7 +176,8 @@ import {
LiquidLevelWidgetComponent,
DoughnutWidgetComponent,
RangeChartWidgetComponent,
BarChartWithLabelsWidgetComponent
BarChartWithLabelsWidgetComponent,
SingleSwitchWidgetComponent
],
providers: [
{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>
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header expandedHeight="48px">
<mat-expansion-panel-header>
<mat-panel-description fxLayoutAlign="start" translate>
widget-config.advanced-title-style
</mat-panel-description>
@ -241,21 +241,13 @@
formControlName="datasources">
</tb-datasources>
</div>
<div class="tb-form-panel" *ngIf="widgetType === widgetTypes.rpc &&
modelValue?.isDataEnabled">
<div class="tb-form-panel-title" translate>widget-config.target-device</div>
<div [formGroup]="targetDeviceSettings">
<tb-entity-alias-select fxFlex
[tbRequired]="!widgetEditMode"
[aliasController]="aliasController"
[allowedEntityTypes]="[entityTypes.DEVICE]"
[callbacks]="widgetConfigCallbacks"
formControlName="targetDeviceAliasId">
</tb-entity-alias-select>
</div>
</div>
<ng-container *ngIf="widgetType === widgetTypes.rpc &&
modelValue?.isDataEnabled" [formGroup]="targetDeviceSettings">
<tb-target-device formControlName="targetDevice">
</tb-target-device>
</ng-container>
<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>
<tb-datasource
formControlName="alarmSource">

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

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

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

@ -19,7 +19,6 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ComponentFactoryResolver,
ComponentRef,
ElementRef,
Inject,
@ -110,12 +109,12 @@ import { MatDialog } from '@angular/material/dialog';
import { ComponentType } from '@angular/cdk/portal';
import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token';
import { MobileService } from '@core/services/mobile.service';
import { DialogService } from '@core/services/dialog.service';
import { PopoverPlacement } from '@shared/components/popover.models';
import { TbPopoverService } from '@shared/components/popover.service';
import { DASHBOARD_PAGE_COMPONENT_TOKEN } from '@home/components/tokens';
import { MODULES_MAP } from '@shared/models/constants';
import { IModulesMap } from '@modules/common/modules-map.models';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
@Component({
selector: 'tb-widget',
@ -179,7 +178,6 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
private route: ActivatedRoute,
private router: Router,
private widgetComponentService: WidgetComponentService,
private componentFactoryResolver: ComponentFactoryResolver,
private elementRef: ElementRef,
private injector: Injector,
private dialog: MatDialog,
@ -198,8 +196,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
private alarmDataService: AlarmDataService,
private translate: TranslateService,
private utils: UtilsService,
private dashboardUtils: DashboardUtilsService,
private mobileService: MobileService,
private dialogs: DialogService,
private raf: RafService,
private ngZone: NgZone,
private cd: ChangeDetectorRef) {
@ -298,6 +296,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.subscriptionContext.entityDataService = this.entityDataService;
this.subscriptionContext.alarmDataService = this.alarmDataService;
this.subscriptionContext.utils = this.utils;
this.subscriptionContext.dashboardUtils = this.dashboardUtils;
this.subscriptionContext.raf = this.raf;
this.subscriptionContext.widgetUtils = this.widgetContext.utils;
this.subscriptionContext.getServerTimeDiff = this.dashboardService.getServerTimeDiff.bind(this.dashboardService);
@ -959,7 +958,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.loadingData = false;
options = {
type: this.widget.type,
targetDeviceAliasIds: this.widget.config.targetDeviceAliasIds
targetDevice: this.widget.config.targetDevice
};
options.callbacks = {
rpcStateChanged: (subscription) => {
@ -974,7 +973,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest;
this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText;
this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection;
this.clearMessage();
if (this.typeParameters.displayRpcMessageToast) {
this.clearMessage();
}
this.detectChanges();
}
},
@ -983,7 +984,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest;
this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText;
this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection;
if (subscription.rpcErrorText) {
if (subscription.rpcErrorText && this.typeParameters.displayRpcMessageToast) {
this.displayMessage('error', subscription.rpcErrorText);
}
this.detectChanges();
@ -993,7 +994,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
if (this.dynamicWidgetComponent) {
this.dynamicWidgetComponent.rpcErrorText = null;
this.dynamicWidgetComponent.rpcRejection = null;
this.clearMessage();
if (this.typeParameters.displayRpcMessageToast) {
this.clearMessage();
}
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 { TenantId } from '@shared/models/id/tenant-id';
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 { AppState } from '@core/core.state';
import {
@ -116,7 +122,7 @@ export interface WidgetAction extends IWidgetAction {
}
export interface IDashboardWidget {
updateWidgetParams();
updateWidgetParams(): void;
}
export class WidgetContext {
@ -482,9 +488,14 @@ export class LabelVariablePattern {
update() {
let label = this.pattern;
const datasource = this.ctx.defaultSubscription?.firstDatasource;
if (this.hasVariables && datasource) {
label = createLabelFromDatasource(datasource, label);
if (this.hasVariables) {
if (this.ctx.defaultSubscription?.type === widgetType.rpc) {
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) {
this.labelSubject.next(label);
@ -503,7 +514,7 @@ export interface IDynamicWidgetComponent {
executingRpcRequest: boolean;
rpcEnabled: boolean;
rpcErrorText: string;
rpcRejection: HttpErrorResponse;
rpcRejection: HttpErrorResponse | Error;
raf: RafService;
[key: string]: any;
}

24
ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html

@ -29,17 +29,19 @@
<mat-card-content>
<form [formGroup]="generalSettings" (ngSubmit)="save()" class="tb-form-panel no-border no-padding">
<fieldset [disabled]="isLoading$ | async">
<mat-form-field class="mat-block">
<mat-label translate>admin.base-url</mat-label>
<input matInput formControlName="baseUrl" required/>
<mat-error *ngIf="generalSettings.get('baseUrl').hasError('required')">
{{ 'admin.base-url-required' | translate }}
</mat-error>
</mat-form-field>
<div class="tb-form-row" tb-hint-tooltip-icon="{{ 'admin.prohibit-different-url-hint' | translate }}">
<mat-slide-toggle class="mat-slide margin" formControlName="prohibitDifferentUrl">
{{ 'admin.prohibit-different-url' | translate }}
</mat-slide-toggle>
<div>
<mat-form-field class="mat-block">
<mat-label translate>admin.base-url</mat-label>
<input matInput formControlName="baseUrl" required/>
<mat-error *ngIf="generalSettings.get('baseUrl').hasError('required')">
{{ 'admin.base-url-required' | translate }}
</mat-error>
</mat-form-field>
<div class="tb-form-row" tb-hint-tooltip-icon="{{ 'admin.prohibit-different-url-hint' | translate }}">
<mat-slide-toggle class="mat-slide margin" formControlName="prohibitDifferentUrl">
{{ 'admin.prohibit-different-url' | translate }}
</mat-slide-toggle>
</div>
</div>
</fieldset>
<div fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px" class="layout-wrap">

2
ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.scss

@ -126,6 +126,8 @@
padding-right: 38px;
overflow: scroll;
padding-bottom: 4px;
min-height: 42px;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;

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

@ -15,7 +15,7 @@
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">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
<section class="tb-rulechain-container" fxFlex fxLayout="column">
@ -34,7 +34,7 @@
*ngIf="!isImport"
[ruleChainType]="ruleChainType"
[disabled]="isDirtyValue"
[(ngModel)]="ruleChain.id.id"
[(ngModel)]="ruleChain"
(ngModelChange)="currentRuleChainIdChanged(ruleChain.id?.id)">
</tb-rule-chain-select>
</mat-toolbar>
@ -129,9 +129,9 @@
<div id="tb-node-content" class="mat-padding" fxLayout="column">
<div class="tb-node-title">{{editingRuleNode.component.name}}</div>
<div>&nbsp;</div>
<div class="tb-node-description">{{editingRuleNode.component.configurationDescriptor.nodeDefinition.description}}</div>
<div class="tb-node-description" [innerHtml]="editingRuleNode.component.configurationDescriptor.nodeDefinition.description | safe: 'html'"></div>
<div>&nbsp;</div>
<div class="tb-node-details" [innerHtml]="editingRuleNode.component.configurationDescriptor.nodeDefinition.details"></div>
<div class="tb-node-details" [innerHtml]="editingRuleNode.component.configurationDescriptor.nodeDefinition.details | safe: 'html'"></div>
</div>
</div>
</mat-tab>

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" 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>
<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">
@ -25,8 +25,8 @@
placeholder="{{ 'widget.widget-title' | translate }}"/>
</mat-form-field>
<mat-form-field class="tb-appearance-transparent">
<mat-select [disabled]="isReadOnly" matInput placeholder="{{ 'widget.type' | translate }}"
[(ngModel)]="widget.type" (ngModelChange)="widetTypeChanged()">
<mat-select [disabled]="isReadOnly" placeholder="{{ 'widget.type' | translate }}"
[(ngModel)]="widget.type" (ngModelChange)="widgetTypeChanged()">
<mat-option *ngFor="let type of allWidgetTypes" [value]="type">
{{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }}
</mat-option>
@ -322,7 +322,7 @@
<mat-spinner diameter="100" mode="indeterminate" color="warn"></mat-spinner>
</div>
<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;">
<button mat-icon-button
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 { WidgetService } from '@core/http/widget.service';
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 { deepClone } from '@core/utils';
import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard';
@ -789,12 +796,12 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
this.isDirty = true;
}
widetTypeChanged() {
widgetTypeChanged() {
const config: WidgetConfig = JSON.parse(this.widget.defaultConfig);
if (this.widget.type !== widgetType.rpc &&
this.widget.type !== widgetType.alarm) {
if (config.targetDeviceAliases) {
delete config.targetDeviceAliases;
if (config.targetDevice) {
delete config.targetDevice;
}
if (config.alarmSource) {
delete config.alarmSource;
@ -819,15 +826,17 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe
if (config.timewindow) {
delete config.timewindow;
}
if (!config.targetDeviceAliases) {
config.targetDeviceAliases = [];
if (!config.targetDevice) {
config.targetDevice = {
type: TargetDeviceType.device
};
}
} else { // alarm
if (config.datasources) {
delete config.datasources;
}
if (config.targetDeviceAliases) {
delete config.targetDeviceAliases;
if (config.targetDevice) {
delete config.targetDevice;
}
if (!config.alarmSource) {
config.alarmSource = {};

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

@ -37,7 +37,7 @@
[displayWith]="displayWidgetTypeFn">
<mat-option *ngFor="let widgetType of filteredWidgetTypes | async" [value]="widgetType">
<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-text" [innerHTML]="widgetType.name | highlight:searchText:true"></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 { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators';
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 { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
import { WidgetTypeInfo } from '@shared/models/widget.models';
import { coerceBoolean } from '@shared/decorators/coercion';
import { WidgetService } from '@core/http/widget.service';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { isDefinedAndNotNull } from '@core/utils';
@Component({
selector: 'tb-widget-type-autocomplete',
@ -90,12 +86,10 @@ export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, On
searchText = '';
private propagateChange = (v: any) => { };
private propagateChange = (_v: any) => { };
constructor(private store: Store<AppState>,
public translate: TranslateService,
constructor(public translate: TranslateService,
private widgetService: WidgetService,
private sanitizer: DomSanitizer,
private fb: UntypedFormBuilder) {
this.selectWidgetTypeFormGroup = this.fb.group({
widgetType: [null]
@ -106,7 +100,7 @@ export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, On
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
registerOnTouched(_fn: any): void {
}
ngOnInit() {
@ -114,7 +108,7 @@ export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, On
.pipe(
debounceTime(150),
tap(value => {
let modelValue;
let modelValue: WidgetTypeInfo;
if (typeof value === 'string' || !value) {
modelValue = null;
} else {
@ -173,13 +167,6 @@ export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, On
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>> {
this.searchText = searchText;
const pageLink = new PageLink(10, 0, searchText, {

49
ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts

@ -16,28 +16,24 @@
import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { Observable, of, Subscription, throwError } from 'rxjs';
import { Observable, of, ReplaySubject, Subscription, throwError } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
publishReplay,
refCount,
switchMap,
tap
tap,
share,
} from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { DeviceService } from '@core/http/device.service';
import { EntitySubtype, EntityType } from '@app/shared/models/entity-type.models';
import { EntityType } from '@app/shared/models/entity-type.models';
import { BroadcastService } from '@app/core/services/broadcast.service';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { AssetService } from '@core/http/asset.service';
import { EntityViewService } from '@core/http/entity-view.service';
import { EdgeService } from '@core/http/edge.service';
import { MatFormFieldAppearance } from '@angular/material/form-field';
import { EntityService } from '@core/http/entity.service';
@Component({
selector: 'tb-entity-subtype-autocomplete',
@ -100,11 +96,8 @@ export class EntitySubTypeAutocompleteComponent implements ControlValueAccessor,
constructor(private store: Store<AppState>,
private broadcast: BroadcastService,
public translate: TranslateService,
private deviceService: DeviceService,
private assetService: AssetService,
private edgeService: EdgeService,
private entityViewService: EntityViewService,
private fb: UntypedFormBuilder) {
private fb: UntypedFormBuilder,
private entityService: EntityService) {
this.subTypeFormGroup = this.fb.group({
subType: [null, Validators.maxLength(255)]
});
@ -229,32 +222,22 @@ export class EntitySubTypeAutocompleteComponent implements ControlValueAccessor,
getSubTypes(): Observable<Array<string>> {
if (!this.subTypes) {
let subTypesObservable: Observable<Array<EntitySubtype>>;
switch (this.entityType) {
case EntityType.ASSET:
subTypesObservable = this.assetService.getAssetTypes({ignoreLoading: true});
break;
case EntityType.DEVICE:
subTypesObservable = this.deviceService.getDeviceTypes({ignoreLoading: true});
break;
case EntityType.EDGE:
subTypesObservable = this.edgeService.getEdgeTypes({ignoreLoading: true});
break;
case EntityType.ENTITY_VIEW:
subTypesObservable = this.entityViewService.getEntityViewTypes({ignoreLoading: true});
break;
}
const subTypesObservable = this.entityService.getEntitySubtypesObservable(this.entityType);
if (subTypesObservable) {
const excludeSubTypesSet = new Set(this.excludeSubTypes);
this.subTypes = subTypesObservable.pipe(
catchError(() => of([] as Array<EntitySubtype>)),
catchError(() => of([] as Array<string>)),
map(subTypes => {
const filteredSubTypes: Array<string> = [];
subTypes.forEach(subType => !excludeSubTypesSet.has(subType.type) && filteredSubTypes.push(subType.type));
subTypes.forEach(subType => !excludeSubTypesSet.has(subType) && filteredSubTypes.push(subType));
return filteredSubTypes;
}),
publishReplay(1),
refCount()
share({
connector: () => new ReplaySubject(1),
resetOnError: false,
resetOnComplete: false,
resetOnRefCountZero: false,
})
);
} else {
return throwError(null);

29
ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts

@ -22,10 +22,6 @@ import { TranslateService } from '@ngx-translate/core';
import { EntitySubtype, EntityType } from '@shared/models/entity-type.models';
import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipGrid, MatChipInputEvent } from '@angular/material/chips';
import { AssetService } from '@core/http/asset.service';
import { DeviceService } from '@core/http/device.service';
import { EdgeService } from '@core/http/edge.service';
import { EntityViewService } from '@core/http/entity-view.service';
import { BroadcastService } from '@core/services/broadcast.service';
import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes';
import { AlarmService } from '@core/http/alarm.service';
@ -34,6 +30,7 @@ import { coerceArray, coerceBoolean } from '@shared/decorators/coercion';
import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data';
import { UtilsService } from '@core/services/utils.service';
import { EntityService } from '@core/http/entity.service';
@Component({
selector: 'tb-entity-subtype-list',
@ -125,13 +122,10 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
constructor(private broadcast: BroadcastService,
public translate: TranslateService,
private assetService: AssetService,
private deviceService: DeviceService,
private edgeService: EdgeService,
private entityViewService: EntityViewService,
private alarmService: AlarmService,
private utils: UtilsService,
private fb: FormBuilder) {
private fb: FormBuilder,
private entityService: EntityService) {
this.entitySubtypeListFormGroup = this.fb.group({
entitySubtypeList: [this.entitySubtypeList, this.required ? [Validators.required] : []],
entitySubtype: [null]
@ -326,24 +320,9 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
}
}
if (!this.entitySubtypes) {
let subTypesObservable: Observable<Array<EntitySubtype>>;
switch (this.entityType) {
case EntityType.ASSET:
subTypesObservable = this.assetService.getAssetTypes({ignoreLoading: true});
break;
case EntityType.DEVICE:
subTypesObservable = this.deviceService.getDeviceTypes({ignoreLoading: true});
break;
case EntityType.EDGE:
subTypesObservable = this.edgeService.getEdgeTypes({ignoreLoading: true});
break;
case EntityType.ENTITY_VIEW:
subTypesObservable = this.entityViewService.getEntityViewTypes({ignoreLoading: true});
break;
}
const subTypesObservable = this.entityService.getEntitySubtypesObservable(this.entityType);
if (subTypesObservable) {
this.entitySubtypes = subTypesObservable.pipe(
map(subTypes => subTypes.map(subType => subType.type)),
share({
connector: () => new ReplaySubject(1),
resetOnError: false,

81
ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts

@ -17,17 +17,15 @@
import { AfterViewInit, Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core';
import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Observable, Subject, Subscription, throwError } from 'rxjs';
import { map, mergeMap, publishReplay, refCount, startWith, tap } from 'rxjs/operators';
import { map, mergeMap, startWith, tap, share } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { DeviceService } from '@core/http/device.service';
import { EntitySubtype, EntityType } from '@app/shared/models/entity-type.models';
import { EntityType } from '@app/shared/models/entity-type.models';
import { BroadcastService } from '@app/core/services/broadcast.service';
import { AssetService } from '@core/http/asset.service';
import { EdgeService } from '@core/http/edge.service';
import { EntityViewService } from '@core/http/entity-view.service';
import { SubscriptSizing } from '@angular/material/form-field';
import { isNotEmptyStr } from '@core/utils';
import { EntityService } from '@core/http/entity.service';
@Component({
selector: 'tb-entity-subtype-select',
@ -66,11 +64,11 @@ export class EntitySubTypeSelectComponent implements ControlValueAccessor, OnIni
entitySubtypeTitle: string;
entitySubtypeRequiredText: string;
subTypesOptions: Observable<Array<EntitySubtype | string>>;
subTypesOptions: Observable<Array<string>>;
private subTypesOptionsSubject: Subject<string> = new Subject();
subTypes: Observable<Array<EntitySubtype | string>>;
private subTypes: Observable<Array<string>>;
subTypesLoaded = false;
@ -81,11 +79,8 @@ export class EntitySubTypeSelectComponent implements ControlValueAccessor, OnIni
constructor(private store: Store<AppState>,
private broadcast: BroadcastService,
public translate: TranslateService,
private deviceService: DeviceService,
private assetService: AssetService,
private edgeService: EdgeService,
private entityViewService: EntityViewService,
private fb: UntypedFormBuilder) {
private fb: UntypedFormBuilder,
private entityService: EntityService) {
this.subTypeFormGroup = this.fb.group({
subType: ['']
});
@ -136,7 +131,7 @@ export class EntitySubTypeSelectComponent implements ControlValueAccessor, OnIni
}
this.subTypesOptions = this.subTypesOptionsSubject.asObservable().pipe(
startWith<string | EntitySubtype>(''),
startWith<string>(''),
mergeMap(() => this.getSubTypes())
);
@ -146,7 +141,7 @@ export class EntitySubTypeSelectComponent implements ControlValueAccessor, OnIni
if (!value || value === '') {
modelValue = '';
} else {
modelValue = value.type;
modelValue = value;
}
this.updateView(modelValue);
}
@ -193,65 +188,41 @@ export class EntitySubTypeSelectComponent implements ControlValueAccessor, OnIni
}
}
displaySubTypeFn(subType?: EntitySubtype | string): string | undefined {
if (subType && typeof subType !== 'string') {
displaySubTypeFn(subType?: string): string | undefined {
if (isNotEmptyStr(subType)) {
if (this.typeTranslatePrefix) {
return this.translate.instant(this.typeTranslatePrefix + '.' + subType.type);
return this.translate.instant(this.typeTranslatePrefix + '.' + subType);
} else {
return subType.type;
return subType;
}
} else {
return this.translate.instant('entity.all-subtypes');
}
}
findSubTypes(searchText?: string): Observable<Array<EntitySubtype | string>> {
findSubTypes(searchText: string): Observable<Array<string>> {
return this.getSubTypes().pipe(
map(subTypes => subTypes.filter( subType => {
return searchText ? (typeof subType === 'string' ? false : subType.type === searchText) : false;
}))
map(subTypes => subTypes.filter( subType => subType === searchText))
);
}
getSubTypes(): Observable<Array<EntitySubtype | string>> {
getSubTypes(): Observable<Array<string>> {
if (!this.subTypes) {
switch (this.entityType) {
case EntityType.ASSET:
this.subTypes = this.assetService.getAssetTypes({ignoreLoading: true});
break;
case EntityType.DEVICE:
this.subTypes = this.deviceService.getDeviceTypes({ignoreLoading: true});
break;
case EntityType.EDGE:
this.subTypes = this.edgeService.getEdgeTypes({ignoreLoading: true});
break;
case EntityType.ENTITY_VIEW:
this.subTypes = this.entityViewService.getEntityViewTypes({ignoreLoading: true});
break;
}
if (this.subTypes) {
this.subTypes = this.subTypes.pipe(
map((allSubtypes) => {
allSubtypes.unshift('');
this.subTypesLoaded = true;
return allSubtypes;
const subTypesObservable = this.entityService.getEntitySubtypesObservable(this.entityType);
if (subTypesObservable) {
this.subTypes = subTypesObservable.pipe(
map((subTypes) => {
this.subTypesLoaded = true;
subTypes.unshift('');
return subTypes;
}),
tap((subTypes) => {
const type: EntitySubtype | string = this.subTypeFormGroup.get('subType').value;
const strType = typeof type === 'string' ? type : type.type;
const found = subTypes.find((subType) => {
if (typeof subType === 'string') {
return subType === type;
} else {
return subType.type === strType;
}
});
const found = subTypes.find(subType => subType === this.subTypeFormGroup.get('subType').value);
if (found) {
this.subTypeFormGroup.get('subType').patchValue(found);
}
}),
publishReplay(1),
refCount()
share()
);
} else {
return throwError(null);

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

@ -24,8 +24,7 @@ import {
Output,
Renderer2,
SecurityContext,
SimpleChanges,
ViewContainerRef
SimpleChanges
} from '@angular/core';
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
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';
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: '[tb-fullscreen]'
})
export class FullscreenDirective implements OnChanges, OnDestroy {
@ -60,7 +60,6 @@ export class FullscreenDirective implements OnChanges, OnDestroy {
constructor(public elementRef: ElementRef,
private renderer: Renderer2,
private sanitizer: DomSanitizer,
private viewContainerRef: ViewContainerRef,
private overlay: Overlay) {
}
@ -118,10 +117,10 @@ export class FullscreenDirective implements OnChanges, OnDestroy {
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('.');
let renderValue: string|null =
this.sanitizer.sanitize(SecurityContext.STYLE, value as{} | string);
let renderValue: string | null =
this.sanitizer.sanitize(SecurityContext.STYLE, value);
if (renderValue != null) {
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 { Hotkey } from 'angular2-hotkeys';
import { MousetrapInstance } from 'mousetrap';
import * as Mousetrap from 'mousetrap';
import Mousetrap from 'mousetrap';
import { TbCheatSheetComponent } from '@shared/components/cheatsheet.component';
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector : '[tb-hotkeys]'
})
export class TbHotkeysDirective implements OnInit, OnDestroy {
@Input() hotkeys: Hotkey[] = [];
@Input('tb-hotkeys') hotkeys: Hotkey[] = [];
@Input() cheatSheet: TbCheatSheetComponent;
private mousetrap: MousetrapInstance;
@ -46,7 +47,7 @@ export class TbHotkeysDirective implements OnInit, OnDestroy {
if (this.cheatSheet) {
const hotkeyObj: Hotkey = new Hotkey(
'?',
(event: KeyboardEvent) => {
() => {
this.cheatSheet.toggleCheatSheet();
return false;
},
@ -63,7 +64,7 @@ export class TbHotkeysDirective implements OnInit, OnDestroy {
this.mousetrap.bind((hotkey as Hotkey).combo, (event: KeyboardEvent, combo: string) => {
let shouldExecute = true;
if (event) {
const target: HTMLElement = (event.target || event.srcElement) as HTMLElement;
const target: HTMLElement = event.target as HTMLElement;
const nodeName: string = target.nodeName.toUpperCase();
if ((' ' + target.className + ' ').indexOf(' mousetrap ') > -1) {
shouldExecute = true;

8
ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts

@ -72,8 +72,12 @@ export class OtaPackageAutocompleteComponent implements ControlValueAccessor, On
@Input()
set deviceProfileId(value: string) {
this.deviceProfile = value;
this.reset();
if (this.deviceProfile !== value) {
if (this.deviceProfile) {
this.reset();
}
this.deviceProfile = value;
}
}
@Input()

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;
max-width: 100vw;
max-height: 100vh;
&.strict-position {
width: 100%;
height: 100%;
}
&::after {
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;
width: 100%;
height: 100%;
&.strict-position {
display: flex;
overflow: hidden;
}
}
// Arrows

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

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

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;
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 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) {
if (
position.connectionPair.originX === POSITION_MAP[placement].originX &&
@ -60,9 +77,10 @@ export function getPlacementName(position: ConnectedOverlayPositionChange): Popo
}
}
return undefined;
}
};
export interface PropertyMapping {
// @ts-ignore
[key: string]: [string, () => unknown];
}

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

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

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

Loading…
Cancel
Save