Browse Source

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

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

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

@ -20,6 +20,7 @@ import io.swagger.annotations.ApiParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.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);
}
}

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

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 {

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

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

@ -501,9 +501,13 @@ export class EntityService {
}
public findEntityKeysByQuery(query: EntityDataQuery, attributes = true, timeseries = true,
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: [],

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;

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

@ -24,6 +24,7 @@ import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-t
import { HttpErrorResponse } from '@angular/common/http';
import { 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[] {

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/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];

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

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;

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

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

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

2
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">

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

83
ui-ngx/src/form.scss

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

3
ui-ngx/src/styles.scss

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

Loading…
Cancel
Save