diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java index 261c4705a9..8a8055c4af 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java @@ -75,7 +75,7 @@ public class OAuth2Controller extends BaseController { @ApiOperation(value = "Get OAuth2 clients (getOAuth2Clients)", notes = "Get the list of OAuth2 clients " + "to log in with, available for such domain scheme (HTTP or HTTPS) (if x-forwarded-proto request header is present - " + "the scheme is known from it) and domain name and port (port may be known from x-forwarded-port header)") - @PostMapping(value = "/noauth/oauth2/client") + @PostMapping(value = "/noauth/oauth2Clients") public List getOAuth2Clients(HttpServletRequest request, @Parameter(description = "Mobile application package name, to find OAuth2 clients " + "where there is configured mobile application with such package name") diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/edge/DefaultTbEdgeService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/edge/DefaultTbEdgeService.java index 5b8fd949bd..8771b48c35 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/edge/DefaultTbEdgeService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/edge/DefaultTbEdgeService.java @@ -54,7 +54,7 @@ public class DefaultTbEdgeService extends AbstractTbEntityService implements TbE if (ActionType.ADDED.equals(actionType)) { ruleChainService.assignRuleChainToEdge(tenantId, edgeTemplateRootRuleChain.getId(), edgeId); - edgeService.setEdgeRootRuleChain(tenantId, savedEdge, edgeTemplateRootRuleChain.getId()); + savedEdge = edgeService.setEdgeRootRuleChain(tenantId, savedEdge, edgeTemplateRootRuleChain.getId()); edgeService.assignDefaultRuleChainsToEdge(tenantId, edgeId); } diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index 1c53920a93..6025e874b9 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -157,7 +157,8 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService() { @Override - public void onSuccess(@Nullable DeviceStateData state) { + public void onSuccess(DeviceStateData state) { TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, device.getId()); - if (addDeviceUsingState(tpi, state)) { - save(deviceId, ACTIVITY_STATE, false); + Set deviceIds = partitionedEntities.get(tpi); + boolean isMyPartition = deviceIds != null; + if (isMyPartition) { + deviceIds.add(state.getDeviceId()); + initializeActivityState(deviceId, state); callback.onSuccess(); } else { - log.debug("[{}][{}] Device belongs to external partition. Probably rebalancing is in progress. Topic: {}" - , tenantId, deviceId, tpi.getFullTopicName()); + log.debug("[{}][{}] Device belongs to external partition. Probably rebalancing is in progress. Topic: {}", tenantId, deviceId, tpi.getFullTopicName()); callback.onFailure(new RuntimeException("Device belongs to external partition " + tpi.getFullTopicName() + "!")); } } @@ -400,6 +403,21 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService deviceIdSet = partitionedEntities.get(tpi); + if (deviceIdSet != null) { + deviceIdSet.remove(deviceId); + } + } + + private void initializeActivityState(DeviceId deviceId, DeviceStateData fetchedState) { + DeviceStateData cachedState = deviceStates.putIfAbsent(fetchedState.getDeviceId(), fetchedState); + boolean activityState = Objects.requireNonNullElse(cachedState, fetchedState).getState().isActive(); + save(deviceId, ACTIVITY_STATE, activityState); + } + @Override protected Map>> onAddedPartitions(Set addedPartitions) { var result = new HashMap>>(); @@ -436,10 +454,16 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService deviceIds = partitionedEntities.get(tpi); + boolean isMyPartition = deviceIds != null; + if (isMyPartition) { + deviceIds.add(state.getDeviceId()); + deviceStates.putIfAbsent(state.getDeviceId(), state); + checkAndUpdateState(state.getDeviceId(), state); + } else { + log.debug("[{}] Device belongs to external partition {}", state.getDeviceId(), tpi.getFullTopicName()); } - checkAndUpdateState(state.getDeviceId(), state); } log.info("[{}] Initialized {} out of {} device states", entry.getKey().getPartition().orElse(0), counter.addAndGet(states.size()), entry.getValue().size()); } @@ -475,18 +499,6 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService deviceIds = partitionedEntities.get(tpi); - if (deviceIds != null) { - deviceIds.add(state.getDeviceId()); - deviceStates.putIfAbsent(state.getDeviceId(), state); - return true; - } else { - log.debug("[{}] Device belongs to external partition {}", state.getDeviceId(), tpi.getFullTopicName()); - return false; - } - } - void checkStates() { try { final long ts = getCurrentTimeMillis(); @@ -619,15 +631,6 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService deviceIdSet = partitionedEntities.get(tpi); - if (deviceIdSet != null) { - deviceIdSet.remove(deviceId); - } - } - @Override protected void cleanupEntityOnPartitionRemoval(DeviceId deviceId) { cleanupEntity(deviceId); diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java index 9833396a0a..296191d61b 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.state; +import com.google.common.util.concurrent.Futures; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,8 +29,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger; @@ -41,11 +44,13 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.sql.query.EntityQueryRepository; import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; @@ -66,6 +71,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -1070,4 +1076,106 @@ public class DefaultDeviceStateServiceTest { then(service).should().fetchDeviceStateDataUsingSeparateRequests(deviceId); } + @Test + public void givenDeviceAdded_whenOnQueueMsg_thenShouldCacheAndSaveActivityToFalse() throws InterruptedException { + // GIVEN + final long defaultTimeout = 1000; + initStateService(defaultTimeout); + given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId)); + given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList())); + + TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setAdded(true) + .setUpdated(false) + .setDeleted(false) + .build(); + + // WHEN + service.onQueueMsg(proto, TbCallback.EMPTY); + + // THEN + await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(false); + then(telemetrySubscriptionService).should().saveAttrAndNotify(eq(TenantId.SYS_TENANT_ID), eq(deviceId), eq(AttributeScope.SERVER_SCOPE), eq(ACTIVITY_STATE), eq(false), any()); + }); + } + + @Test + public void givenDeviceActivityEventHappenedAfterAdded_whenOnDeviceActivity_thenShouldCacheAndSaveActivityToTrue() throws InterruptedException { + // GIVEN + final long defaultTimeout = 1000; + initStateService(defaultTimeout); + long currentTime = System.currentTimeMillis(); + DeviceState deviceState = DeviceState.builder() + .active(false) + .inactivityTimeout(service.getDefaultInactivityTimeoutInSec()) + .build(); + DeviceStateData stateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .deviceCreationTime(currentTime - 10000) + .state(deviceState) + .metaData(TbMsgMetaData.EMPTY) + .build(); + service.deviceStates.put(deviceId, stateData); + + // WHEN + service.onDeviceActivity(tenantId, deviceId, currentTime); + + // THEN + await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(true); + then(telemetrySubscriptionService).should().saveAttrAndNotify(eq(TenantId.SYS_TENANT_ID), eq(deviceId), eq(AttributeScope.SERVER_SCOPE), eq(LAST_ACTIVITY_TIME), eq(currentTime), any()); + then(telemetrySubscriptionService).should().saveAttrAndNotify(eq(TenantId.SYS_TENANT_ID), eq(deviceId), eq(AttributeScope.SERVER_SCOPE), eq(ACTIVITY_STATE), eq(true), any()); + }); + } + + @Test + public void givenDeviceActivityEventHappenedBeforeAdded_whenOnQueueMsg_thenShouldSaveActivityStateUsingValueFromCache() throws InterruptedException { + // GIVEN + final long defaultTimeout = 1000; + initStateService(defaultTimeout); + given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId)); + given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList())); + + long currentTime = System.currentTimeMillis(); + DeviceState deviceState = DeviceState.builder() + .active(true) + .lastConnectTime(currentTime - 8000) + .lastActivityTime(currentTime - 4000) + .lastDisconnectTime(0) + .lastInactivityAlarmTime(0) + .inactivityTimeout(3000) + .build(); + DeviceStateData stateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .deviceCreationTime(currentTime - 10000) + .state(deviceState) + .build(); + service.deviceStates.put(deviceId, stateData); + + // WHEN + TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setAdded(true) + .setUpdated(false) + .setDeleted(false) + .build(); + service.onQueueMsg(proto, TbCallback.EMPTY); + + // THEN + await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(service.deviceStates.get(deviceId).getState().isActive()).isEqualTo(true); + then(telemetrySubscriptionService).should().saveAttrAndNotify(eq(TenantId.SYS_TENANT_ID), eq(deviceId), eq(AttributeScope.SERVER_SCOPE), eq(ACTIVITY_STATE), eq(true), any()); + }); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java index ffc88f7f5a..2e01d700a9 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -241,7 +241,7 @@ public class VersionControlTest extends AbstractControllerTest { checkImportedEntity(tenantId1, device, tenantId1, importedDevice); assertThat(importedDevice.getDeviceProfileId()).isEqualTo(device.getDeviceProfileId()); - assertThat(findDeviceCredentials(device.getId())).isEqualTo(deviceCredentials); + assertThat(findDeviceCredentials(device.getId())).isEqualToIgnoringGivenFields(deviceCredentials, "version"); assertThat(importedDevice.getFirmwareId()).isEqualTo(firmware.getId()); assertThat(importedDevice.getSoftwareId()).isEqualTo(software.getId()); } diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/edge/RelatedEdgesCacheValue.java b/common/cache/src/main/java/org/thingsboard/server/cache/edge/RelatedEdgesCacheValue.java index a46b2c5875..1289d09353 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/edge/RelatedEdgesCacheValue.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/edge/RelatedEdgesCacheValue.java @@ -15,7 +15,9 @@ */ package org.thingsboard.server.cache.edge; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.page.PageData; @@ -23,15 +25,13 @@ import java.io.Serial; import java.io.Serializable; @Data +@NoArgsConstructor +@AllArgsConstructor public class RelatedEdgesCacheValue implements Serializable { @Serial private static final long serialVersionUID = -2765080094748518572L; - private final PageData pageData; - - public RelatedEdgesCacheValue(PageData pageData) { - this.pageData = pageData; - } + private PageData pageData; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java index ec8ade7f69..34ba77269d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java @@ -75,9 +75,9 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel this.tenantId = edge.getTenantId(); this.customerId = edge.getCustomerId(); this.rootRuleChainId = edge.getRootRuleChainId(); + this.name = edge.getName(); this.type = edge.getType(); this.label = edge.getLabel(); - this.name = edge.getName(); this.routingKey = edge.getRoutingKey(); this.secret = edge.getSecret(); this.version = edge.getVersion(); @@ -87,9 +87,9 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel this.tenantId = edge.getTenantId(); this.customerId = edge.getCustomerId(); this.rootRuleChainId = edge.getRootRuleChainId(); + this.name = edge.getName(); this.type = edge.getType(); this.label = edge.getLabel(); - this.name = edge.getName(); this.routingKey = edge.getRoutingKey(); this.secret = edge.getSecret(); this.version = edge.getVersion(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java index 2512125ff1..1dbc81bfa1 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java @@ -35,10 +35,13 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.exception.DataValidationException; import java.nio.ByteBuffer; import java.util.concurrent.TimeUnit; +import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; + @Slf4j @RuleNode( type = ComponentType.EXTERNAL, @@ -62,10 +65,9 @@ public class TbAwsLambdaNode extends TbAbstractExternalNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { config = TbNodeUtils.convert(configuration, TbAwsLambdaNodeConfiguration.class); - if (StringUtils.isBlank(config.getFunctionName())) { - throw new TbNodeException("Function name must be set!", true); - } + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; try { + validateFields(config, errorPrefix); AWSCredentials awsCredentials = new BasicAWSCredentials(config.getAccessKey(), config.getSecretKey()); client = AWSLambdaAsyncClientBuilder.standard() .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) @@ -74,6 +76,8 @@ public class TbAwsLambdaNode extends TbAbstractExternalNode { .withConnectionTimeout((int) TimeUnit.SECONDS.toMillis(config.getConnectionTimeout())) .withRequestTimeout((int) TimeUnit.SECONDS.toMillis(config.getRequestTimeout()))) .build(); + } catch (DataValidationException e) { + throw new TbNodeException(e, true); } catch (Exception e) { throw new TbNodeException(e); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeConfiguration.java index c7c66599d5..b395f6b15e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeConfiguration.java @@ -15,6 +15,8 @@ */ package org.thingsboard.rule.engine.aws.lambda; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; import lombok.Data; import org.thingsboard.rule.engine.api.NodeConfiguration; @@ -23,12 +25,18 @@ public class TbAwsLambdaNodeConfiguration implements NodeConfiguration 23) { + throw new TbNodeException("Client ID is too long '" + clientId + "'. " + + "The length of Client ID cannot be longer than 23, but current length is " + clientId.length() + ".", true); + } + return clientId; + } + MqttClient getMqttClient(TbContext ctx, MqttClientConfig config) { return MqttClient.create(config, null, ctx.getExternalCallExecutor()); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeTest.java index 4ee954991c..14ae9bef20 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNodeTest.java @@ -41,6 +41,7 @@ import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -50,8 +51,8 @@ import java.util.Map; import java.util.UUID; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -72,13 +73,17 @@ public class TbAwsLambdaNodeTest { private AWSLambdaAsync clientMock; @BeforeEach - void setUp() { + public void setUp() { node = new TbAwsLambdaNode(); config = new TbAwsLambdaNodeConfiguration().defaultConfiguration(); + config.setAccessKey("accessKey"); + config.setSecretKey("secretKey"); + config.setFunctionName("new-function"); } @Test public void verifyDefaultConfig() { + config = new TbAwsLambdaNodeConfiguration().defaultConfiguration(); assertThat(config.getAccessKey()).isNull(); assertThat(config.getSecretKey()).isNull(); assertThat(config.getRegion()).isEqualTo(("us-east-1")); @@ -94,10 +99,43 @@ public class TbAwsLambdaNodeTest { @ValueSource(strings = " ") public void givenInvalidFunctionName_whenInit_thenThrowsException(String funcName) { config.setFunctionName(funcName); - var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); - assertThatThrownBy(() -> node.init(ctx, configuration)) - .isInstanceOf(TbNodeException.class) - .hasMessage("Function name must be set!"); + verifyValidationExceptionOnInit(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void givenInvalidAccessKey_whenInit_thenThrowsException(String accessKey) { + config.setAccessKey(accessKey); + verifyValidationExceptionOnInit(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void givenInvalidSecretAccessKey_whenInit_thenThrowsException(String secretAccessKey) { + config.setSecretKey(secretAccessKey); + verifyValidationExceptionOnInit(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void givenInvalidRegion_whenInit_thenThrowsException(String region) { + config.setRegion(region); + verifyValidationExceptionOnInit(); + } + + @Test + public void givenInvalidConnectionTimeout_whenInit_thenThrowsException() { + config.setConnectionTimeout(-100); + verifyValidationExceptionOnInit(); + } + + @Test + public void givenInvalidRequestTimeout_whenInit_thenThrowsException() { + config.setRequestTimeout(-100); + verifyValidationExceptionOnInit(); } @ParameterizedTest @@ -280,10 +318,19 @@ public class TbAwsLambdaNodeTest { assertThat(throwableCaptor.getValue()).isInstanceOf(AWSLambdaException.class).hasMessageStartingWith(errorMsg); } + private void verifyValidationExceptionOnInit() { + RuleNode ruleNode = new RuleNode(); + ruleNode.setName("test"); + when(ctx.getSelf()).thenReturn(ruleNode); + String errorPrefix = "'test' node configuration is invalid: "; + assertThatThrownBy(() -> node.init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessageContaining(errorPrefix) + .extracting(e -> ((TbNodeException) e).isUnrecoverable()) + .isEqualTo(true); + } + private void init() { - config.setAccessKey("accessKey"); - config.setSecretKey("secretKey"); - config.setFunctionName("new-function"); ReflectionTestUtils.setField(node, "client", clientMock); ReflectionTestUtils.setField(node, "config", config); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java index da568eefbb..8fa8c387fd 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java @@ -191,6 +191,42 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest { assertThatNoException().isThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))); } + @Test + public void givenClientIdIsTooLong_whenInit_thenThrowsException() { + String invalidClientId = "vhfrbeb38ygwfwrgfwefgterhytjytj"; + mqttNodeConfig.setClientId(invalidClientId); + + given(ctxMock.getTenantId()).willReturn(TENANT_ID); + given(ctxMock.getSelf()).willReturn(new RuleNode(RULE_NODE_ID)); + + assertThatThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("Client ID is too long '" + invalidClientId + "'. " + + "The length of Client ID cannot be longer than 23, but current length is " + invalidClientId.length() + ".") + .extracting(e -> ((TbNodeException) e).isUnrecoverable()) + .isEqualTo(true); + } + + @Test + public void givenClientIdIsOkAndAppendClientIdSuffixIsTrue_whenInit_thenClientIdBecomesInvalidAndThrowsException() { + String validClientId = "fertjnhnjj4ge"; + mqttNodeConfig.setClientId("fertjnhnjj4ge"); + mqttNodeConfig.setAppendClientIdSuffix(true); + + given(ctxMock.getTenantId()).willReturn(TENANT_ID); + given(ctxMock.getSelf()).willReturn(new RuleNode(RULE_NODE_ID)); + String serviceId = "test-service"; + given(ctxMock.getServiceId()).willReturn(serviceId); + + String resultedClientId = validClientId + "_" + serviceId; + assertThatThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("Client ID is too long '" + resultedClientId + "'. " + + "The length of Client ID cannot be longer than 23, but current length is " + resultedClientId.length() + ".") + .extracting(e -> ((TbNodeException) e).isUnrecoverable()) + .isEqualTo(true); + } + @Test public void givenFailedByTimeoutConnectResult_whenInit_thenThrowsException() throws ExecutionException, InterruptedException, TimeoutException { mqttNodeConfig.setHost("localhost"); diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index 6f1c2bc116..5f8a968710 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -224,7 +224,7 @@ export class AuthService { } public loadOAuth2Clients(): Observable> { - const url = '/api/noauth/oauth2/client?platform=' + PlatformType.WEB; + const url = '/api/noauth/oauth2Clients?platform=' + PlatformType.WEB; return this.http.post>(url, null, defaultHttpOptions()).pipe( catchError(err => of([])), diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index e31e22e0fe..50978662df 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -459,7 +459,7 @@ export class EntityService { entitiesObservable = this.queueService.getQueueStatistics(pageLink, config); break; case EntityType.OAUTH2_CLIENT: - pageLink.sortOrder.property = 'createdTime'; + pageLink.sortOrder.property = 'title'; entitiesObservable = this.oauth2Service.findTenantOAuth2ClientInfos(pageLink, config); break; } diff --git a/ui-ngx/src/app/core/http/image.service.ts b/ui-ngx/src/app/core/http/image.service.ts index eb15b658e5..835e8ffb61 100644 --- a/ui-ngx/src/app/core/http/image.service.ts +++ b/ui-ngx/src/app/core/http/image.service.ts @@ -21,11 +21,15 @@ import { defaultHttpOptionsFromConfig, defaultHttpUploadOptions, RequestConfig } import { Observable, of, ReplaySubject } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { - NO_IMAGE_DATA_URI, + ImageExportData, ImageResourceInfo, - imageResourceType, ImageResourceType, - IMAGES_URL_PREFIX, isImageResourceUrl, ImageExportData, removeTbImagePrefix, ResourceSubType + imageResourceType, + IMAGES_URL_PREFIX, + isImageResourceUrl, + NO_IMAGE_DATA_URI, + removeTbImagePrefix, + ResourceSubType } from '@shared/models/resource.models'; import { catchError, map, switchMap } from 'rxjs/operators'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; @@ -46,7 +50,8 @@ export class ImageService { ) { } - public uploadImage(file: File, title: string, imageSubType: ResourceSubType, config?: RequestConfig): Observable { + public uploadImage(file: File, title: string, imageSubType: ResourceSubType = ResourceSubType.IMAGE, + config?: RequestConfig): Observable { if (!config) { config = {}; } @@ -82,8 +87,8 @@ export class ImageService { imageInfo, defaultHttpOptionsFromConfig(config)); } - public getImages(pageLink: PageLink, imageSubType: ResourceSubType, - includeSystemImages = false, config?: RequestConfig): Observable> { + public getImages(pageLink: PageLink, includeSystemImages = false, + imageSubType: ResourceSubType = ResourceSubType.IMAGE, config?: RequestConfig): Observable> { return this.http.get>( `${IMAGES_URL_PREFIX}${pageLink.toQuery()}&imageSubType=${imageSubType}&includeSystemImages=${includeSystemImages}`, defaultHttpOptionsFromConfig(config)); diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index 5b98c7cb7b..5c2363789d 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -59,6 +59,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { BackgroundType, colorBackground, isBackgroundSettings } from '@shared/models/widget-settings.models'; import { MediaBreakpoints } from '@shared/models/constants'; import { TranslateService } from '@ngx-translate/core'; +import { DashboardPageLayout } from '@home/components/dashboard-page/dashboard-page.models'; @Injectable({ providedIn: 'root' @@ -344,10 +345,12 @@ export class DashboardUtilsService { return widgetConfig; } - public prepareWidgetForScadaLayout(widget: Widget): Widget { + public prepareWidgetForScadaLayout(widget: Widget, isScada: boolean): Widget { const config = widget.config; config.showTitle = false; config.dropShadow = false; + config.resizable = !isScada; + config.preserveAspectRatio = isScada; config.padding = '0'; config.margin = '0'; config.backgroundColor = 'rgba(0,0,0,0)'; @@ -653,7 +656,9 @@ export class DashboardUtilsService { mobileOrder: widget.config.mobileOrder, mobileHeight: widget.config.mobileHeight, mobileHide: widget.config.mobileHide, - desktopHide: widget.config.desktopHide + desktopHide: widget.config.desktopHide, + preserveAspectRatio: widget.config.preserveAspectRatio, + resizable: widget.config.resizable }; if (isUndefined(originalColumns)) { originalColumns = 24; @@ -1081,4 +1086,22 @@ export class DashboardUtilsService { const maxStr = isDefined(currentData?.maxWidth) ? `max ${currentData.maxWidth}px` : ''; return minStr && maxStr ? `${minStr} - ${maxStr}` : `${minStr}${maxStr}`; } + + updatedLayoutForBreakpoint(layout: DashboardPageLayout, breakpointId: BreakpointId) { + let selectBreakpointId: BreakpointId = 'default'; + if (layout.layoutCtx.layoutData[breakpointId]) { + selectBreakpointId = breakpointId; + } + layout.layoutCtx.breakpoint = selectBreakpointId; + const layoutInfo = layout.layoutCtx.layoutData[selectBreakpointId]; + if (layoutInfo.gridSettings) { + layout.layoutCtx.gridSettings = layoutInfo.gridSettings; + } + layout.layoutCtx.widgets.setWidgetIds(layoutInfo.widgetIds); + layout.layoutCtx.widgetLayouts = layoutInfo.widgetLayouts; + if (layout.show && layout.layoutCtx.ctrl) { + layout.layoutCtx.ctrl.reload(); + } + layout.layoutCtx.ignoreLoading = true; + } } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts index 1d2fb7bfcf..709159aec3 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts @@ -130,7 +130,14 @@ export class AddWidgetDialogComponent extends DialogComponent { - this.layouts.main.layoutCtx.ctrl.updatedCurrentBreakpoint(); + this.dashboardUtils.updatedLayoutForBreakpoint(this.layouts.main, this.dashboardCtx.breakpoint); this.updateLayoutSizes(); } ) @@ -759,6 +759,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC const prevMainLayoutWidth = this.mainLayoutSize.width; const prevMainLayoutHeight = this.mainLayoutSize.height; const prevMainLayoutMaxWidth = this.mainLayoutSize.maxWidth; + const prevMainLayoutMinWidth = this.mainLayoutSize.minWidth; if (this.isEditingWidget && this.editingLayoutCtx.id === 'main') { this.mainLayoutSize.width = '100%'; } else { @@ -773,19 +774,19 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC const xOffset = this.dashboardContainer.nativeElement.getBoundingClientRect().x; const breakpoint = this.dashboardUtils.getBreakpointInfoById(this.layouts.main.layoutCtx.breakpoint); - let maxWidth: string; - if (breakpoint?.maxWidth) { - if (this.isMobileSize(breakpoint)) { - maxWidth = `${breakpoint.maxWidth}px`; - } else { - maxWidth = `${breakpoint.maxWidth - xOffset}px`; + let maxWidth = '100%'; + let minWidth: string; + + if (breakpoint) { + const isMobile = this.isMobileSize(breakpoint); + if (breakpoint.maxWidth) { + maxWidth = isMobile ? `${breakpoint.maxWidth}px` : `${breakpoint.maxWidth - xOffset}px`; + } + if (breakpoint.minWidth) { + minWidth = isMobile ? `${breakpoint.minWidth}px` : `${breakpoint.minWidth - xOffset}px`; } - } else { - maxWidth = '100%'; } - const minWidth = breakpoint?.minWidth ? `${breakpoint.minWidth}px` : undefined; - this.mainLayoutSize.maxWidth = maxWidth; this.mainLayoutSize.minWidth = minWidth; } else { @@ -793,7 +794,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC this.mainLayoutSize.minWidth = undefined; } return prevMainLayoutWidth !== this.mainLayoutSize.width || prevMainLayoutHeight !== this.mainLayoutSize.height || - prevMainLayoutMaxWidth !== this.mainLayoutSize.maxWidth; + prevMainLayoutMaxWidth !== this.mainLayoutSize.maxWidth || prevMainLayoutMinWidth !== this.mainLayoutSize.minWidth; } private updateRightLayoutSize(): boolean { @@ -920,7 +921,10 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC let gridSettings: GridSettings = null; const layoutKeys = this.dashboardUtils.isSingleLayoutDashboard(this.dashboard); if (layoutKeys) { - gridSettings = deepClone(this.dashboard.configuration.states[layoutKeys.state].layouts[layoutKeys.layout].gridSettings); + const layouts = this.dashboardUtils.getDashboardLayoutConfig( + this.dashboard.configuration.states[layoutKeys.state].layouts[layoutKeys.layout], + this.layouts[layoutKeys.layout].layoutCtx.breakpoint); + gridSettings = deepClone(layouts.gridSettings); } this.dialog.open(DashboardSettingsDialogComponent, { @@ -938,9 +942,11 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC this.updateDashboardCss(); const newGridSettings = data.gridSettings; if (newGridSettings) { - const layout = this.dashboard.configuration.states[layoutKeys.state].layouts[layoutKeys.layout]; - this.dashboardUtils.updateLayoutSettings(layout, newGridSettings); - this.updateLayouts(); + const layouts = deepClone(this.dashboard.configuration.states[layoutKeys.state].layouts); + const layoutConfig = this.dashboardUtils.getDashboardLayoutConfig( + layouts[layoutKeys.layout], this.layouts[layoutKeys.layout].layoutCtx.breakpoint); + this.dashboardUtils.updateLayoutSettings(layoutConfig, newGridSettings); + this.updateDashboardLayouts(layouts); } } }); @@ -1130,7 +1136,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC private updateLayout(layout: DashboardPageLayout, layoutInfo: DashboardLayoutInfo) { layout.layoutCtx.layoutData = layoutInfo; layout.layoutCtx.layoutDataChanged.next(); - layout.layoutCtx.ctrl?.updatedCurrentBreakpoint(this.isEdit ? layout.layoutCtx.breakpoint : null, layout.show); + this.dashboardUtils.updatedLayoutForBreakpoint(layout, this.isEdit ? layout.layoutCtx.breakpoint : this.dashboardCtx.breakpoint); this.updateLayoutSizes(); } @@ -1345,7 +1351,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC } const scada = this.isAddingToScadaLayout(); if (scada) { - newWidget = this.dashboardUtils.prepareWidgetForScadaLayout(newWidget); + newWidget = this.dashboardUtils.prepareWidgetForScadaLayout(newWidget, widgetTypeInfo.scada); } let showLayoutConfig = true; if (scada || this.layouts.right.show || !this.showLayoutConfigInEdit(this.layouts.main.layoutCtx)) { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index f6b427cd3e..0a1733e851 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -152,8 +152,8 @@ warning diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.html index 8baab0f295..15b7ee173a 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.html @@ -30,7 +30,7 @@ layout.breakpoint {{ getName(this.addBreakpointFormGroup.get('newBreakpointId').value) }} - + {{ getIcon(breakpoint) }}
{{ getName(breakpoint) }}
{{ getSizeDescription(breakpoint) }}
@@ -41,7 +41,7 @@ layout.copy-from {{ getName(this.addBreakpointFormGroup.get('copyFrom').value) }} - + {{ getIcon(breakpoint) }}
{{ getName(breakpoint) }}
{{ getSizeDescription(breakpoint) }}
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts index 79eef476b3..9646152eba 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts @@ -332,25 +332,4 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo const pos = this.dashboard.getEventGridPosition($event); this.layoutCtx.dashboardCtrl.pasteWidgetReference($event, this.layoutCtx, pos); } - - updatedCurrentBreakpoint(breakpointId?: BreakpointId, showLayout = true) { - if (!isNotEmptyStr(breakpointId)) { - breakpointId = this.dashboardCtx.breakpoint; - } - if (this.layoutCtx.layoutData[breakpointId]) { - this.layoutCtx.breakpoint = breakpointId; - } else { - this.layoutCtx.breakpoint = 'default'; - } - const layoutInfo = this.layoutCtx.layoutData[this.layoutCtx.breakpoint]; - if (layoutInfo.gridSettings) { - this.layoutCtx.gridSettings = layoutInfo.gridSettings; - } - this.layoutCtx.widgets.setWidgetIds(layoutInfo.widgetIds); - this.layoutCtx.widgetLayouts = layoutInfo.widgetLayouts; - if (showLayout && this.layoutCtx.ctrl) { - this.layoutCtx.ctrl.reload(); - } - this.layoutCtx.ignoreLoading = true; - } } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts index 6aa69c51af..3ea9f553a8 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts @@ -21,7 +21,6 @@ export interface ILayoutController { selectWidget(widgetId: string, delay?: number); pasteWidget($event: MouseEvent); pasteWidgetReference($event: MouseEvent); - updatedCurrentBreakpoint(breakpoint?: string, showLayout?: boolean); } export enum LayoutWidthType { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html index 417c8d9e26..790835352d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html @@ -21,7 +21,7 @@ [(ngModel)]="selectedBreakpoint" (ngModelChange)="selectLayoutChanged()"> {{ getName(selectedBreakpoint) }} - + {{ getIcon(breakpointId) }}
{{ getName(breakpointId) }}
{{ getSizeDescription(breakpointId) }}
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts index 130d0e02b2..6366154d80 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts @@ -63,7 +63,7 @@ export class SelectDashboardBreakpointComponent implements OnInit, OnDestroy { } selectLayoutChanged() { - this.dashboardCtrl.layouts.main.layoutCtx.ctrl.updatedCurrentBreakpoint(this.selectedBreakpoint); + this.dashboardUtils.updatedLayoutForBreakpoint(this.dashboardCtrl.layouts.main, this.selectedBreakpoint); this.dashboardCtrl.updateLayoutSizes(); } diff --git a/ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts b/ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts index 063de82ad2..2d08fd9f50 100644 --- a/ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts @@ -19,10 +19,10 @@ import { AppState } from '@core/core.state'; import { UntypedFormBuilder, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms'; import { ContactBased } from '@shared/models/contact-based.model'; import { AfterViewInit, ChangeDetectorRef, Directive } from '@angular/core'; -import { POSTAL_CODE_PATTERNS } from '@home/models/contact.models'; import { HasId } from '@shared/models/base-data'; import { EntityComponent } from './entity.component'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { CountryData } from '@shared/models/country.models'; @Directive() export abstract class ContactBasedComponent> extends EntityComponent implements AfterViewInit { @@ -31,7 +31,8 @@ export abstract class ContactBasedComponent> exten protected fb: UntypedFormBuilder, protected entityValue: T, protected entitiesTableConfigValue: EntityTableConfig, - protected cd: ChangeDetectorRef) { + protected cd: ChangeDetectorRef, + protected countryData: CountryData) { super(store, fb, entityValue, entitiesTableConfigValue, cd); } @@ -75,9 +76,11 @@ export abstract class ContactBasedComponent> exten zipValidators(country: string): ValidatorFn[] { const zipValidators = []; - if (country && POSTAL_CODE_PATTERNS[country]) { - const postalCodePattern = POSTAL_CODE_PATTERNS[country]; - zipValidators.push(Validators.pattern(postalCodePattern)); + if (country) { + const postCodePattern = this.countryData.allCountries.find(item => item.name === country)?.postCodePattern; + if (postCodePattern) { + zipValidators.push(Validators.pattern(postCodePattern)); + } } return zipValidators; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index c6c07fb1ad..ab25a96af1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -49,7 +49,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { BehaviorSubject, fromEvent, merge, Observable, Subject } from 'rxjs'; import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { EntityId } from '@shared/models/id/entity-id'; -import { entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs/operators'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort, SortDirection } from '@angular/material/sort'; @@ -408,7 +408,9 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni } as EntityColumn ); this.contentsInfo.entityType = { - useCellContentFunction: false + useCellContentFunction: true, + cellContentFunction: (entityType: EntityType) => + entityType ? this.translate.instant(entityTypeTranslations.get(entityType).type) : '' }; this.stylesInfo.entityType = { useCellStyleFunction: false @@ -869,9 +871,9 @@ class EntityDatasource implements DataSource { } if (datasource.entityType) { entity.id.entityType = datasource.entityType; - entity.entityType = this.translate.instant(entityTypeTranslations.get(datasource.entityType).type); + entity.entityType = datasource.entityType; } else { - entity.entityType = ''; + entity.entityType = null; } this.dataKeys.forEach((dataKey, index) => { const keyData = data[index].data; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html index 72b5d8a7f9..6c73e47574 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html @@ -48,7 +48,7 @@ {{ 'widgets.recent-dashboards.name' | translate }} - {{ lastVisitedDashboard.title }} + {{ lastVisitedDashboard.title | customTranslate }} @@ -78,7 +78,7 @@ class="star" [ngClass]="{'starred': dashboard.starred}">{{ dashboard.starred ? 'star' : 'star_border' }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html index dcab07b89b..7f10c4d211 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html @@ -123,8 +123,8 @@ {{key.label}} + (openedChange)="key.isFocused = $event" + (selectionChange)="inputChanged(source, key)"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 33428e949d..dcb40cd85d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -25,6 +25,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; +import { EntityType } from '@shared/models/entity-type.models'; type ColumnVisibilityOptions = 'visible' | 'hidden' | 'hidden-mobile'; @@ -65,7 +66,7 @@ export interface EntityData { id: EntityId; entityName: string; entityLabel?: string; - entityType?: string; + entityType?: EntityType; actionCellButtons?: TableCellButtonActionDescriptor[]; hasActions?: boolean; [key: string]: any; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index b10af3e2f4..3babbfe13f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -168,18 +168,32 @@ -
-
- - {{ 'widget-config.mobile-hide' | translate }} - -
-
- - {{ 'widget-config.desktop-hide' | translate }} - +
+
+
widget-config.resize-options
+
+ + {{ 'widget-config.resizable' | translate }} + +
+
+ + {{ 'widget-config.preserve-aspect-ratio' | translate }} + +
-
+
+
{{ (isDefaultBreakpoint ? 'widget-config.mobile' : 'widget-config.list-layout') | translate }}
+
+ + {{ 'widget-config.mobile-hide' | translate }} + +
+
+ + {{ 'widget-config.desktop-hide' | translate }} + +
widget-config.order
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index f554c4bf3f..735212c2b3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -63,7 +63,7 @@ import { UtilsService } from '@core/services/utils.service'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; -import { Observable, of, Subject, Subscription } from 'rxjs'; +import { merge, Observable, of, Subject, Subscription } from 'rxjs'; import { IBasicWidgetConfigComponent, WidgetConfigCallbacks @@ -248,19 +248,24 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe noDataDisplayMessage: [null, []] }); - this.widgetSettings.get('showTitle').valueChanges.subscribe(() => { - this.updateWidgetSettingsEnabledState(); - }); - this.widgetSettings.get('showTitleIcon').valueChanges.subscribe(() => { + merge(this.widgetSettings.get('showTitle').valueChanges, + this.widgetSettings.get('showTitleIcon').valueChanges).subscribe(() => { this.updateWidgetSettingsEnabledState(); }); this.layoutSettings = this.fb.group({ + resizable: [true], + preserveAspectRatio: [false], mobileOrder: [null, [Validators.pattern(/^-?[0-9]+$/)]], mobileHeight: [null, [Validators.min(1), Validators.pattern(/^\d*$/)]], mobileHide: [false], desktopHide: [false] }); + + this.layoutSettings.get('resizable').valueChanges.subscribe(() => { + this.updateLayoutEnabledState(); + }); + this.actionsSettings = this.fb.group({ actions: [null, []] }); @@ -349,16 +354,12 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe value: 'actions' } ); - if (this.showLayoutConfig) { - this.headerOptions.push( - { - name: this.isDefaultBreakpoint - ? this.translate.instant('widget-config.mobile') - : this.translate.instant('widget-config.list-layout'), - value: 'mobile' - } - ); - } + this.headerOptions.push( + { + name: this.translate.instant('widget-config.layout'), + value: 'layout' + } + ); if (!this.selectedOption || !this.headerOptions.find(o => o.value === this.selectedOption)) { this.selectedOption = this.headerOptions[0].value; } @@ -569,6 +570,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe if (layout) { this.layoutSettings.patchValue( { + resizable: isDefined(layout.resizable) ? layout.resizable : true, + preserveAspectRatio: layout.preserveAspectRatio, mobileOrder: layout.mobileOrder, mobileHeight: layout.mobileHeight, mobileHide: layout.mobileHide, @@ -579,6 +582,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe } else { this.layoutSettings.patchValue( { + resizable: true, + preserveAspectRatio: false, mobileOrder: null, mobileHeight: null, mobileHide: false, @@ -587,6 +592,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe {emitEvent: false} ); } + this.updateLayoutEnabledState(); } this.createChangeSubscriptions(); } @@ -620,6 +626,15 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe } } + private updateLayoutEnabledState() { + const resizable: boolean = this.layoutSettings.get('resizable').value; + if (resizable) { + this.layoutSettings.get('preserveAspectRatio').enable({emitEvent: false}); + } else { + this.layoutSettings.get('preserveAspectRatio').disable({emitEvent: false}); + } + } + private updateSchemaForm(settings?: any) { const widgetSettingsFormData: JsonFormComponentData = {}; if (this.modelValue.settingsSchema && this.modelValue.settingsSchema.schema) { diff --git a/ui-ngx/src/app/modules/home/models/contact.models.ts b/ui-ngx/src/app/modules/home/models/contact.models.ts deleted file mode 100644 index 41ebecc865..0000000000 --- a/ui-ngx/src/app/modules/home/models/contact.models.ts +++ /dev/null @@ -1,291 +0,0 @@ -/// -/// 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. -/// - -export const COUNTRIES = [ - 'Afghanistan', - 'Åland Islands', - 'Albania', - 'Algeria', - 'American Samoa', - 'Andorra', - 'Angola', - 'Anguilla', - 'Antarctica', - 'Antigua and Barbuda', - 'Argentina', - 'Armenia', - 'Aruba', - 'Australia', - 'Austria', - 'Azerbaijan', - 'Bahamas', - 'Bahrain', - 'Bangladesh', - 'Barbados', - 'Belarus', - 'Belgium', - 'Belize', - 'Benin', - 'Bermuda', - 'Bhutan', - 'Bolivia', - 'Bonaire, Sint Eustatius and Saba', - 'Bosnia and Herzegovina', - 'Botswana', - 'Bouvet Island', - 'Brazil', - 'British Indian Ocean Territory', - 'Brunei Darussalam', - 'Bulgaria', - 'Burkina Faso', - 'Burundi', - 'Cambodia', - 'Cameroon', - 'Canada', - 'Cape Verde', - 'Cayman Islands', - 'Central African Republic', - 'Chad', - 'Chile', - 'China', - 'Christmas Island', - 'Cocos (Keeling) Islands', - 'Colombia', - 'Comoros', - 'Congo', - 'Congo, The Democratic Republic of the', - 'Cook Islands', - 'Costa Rica', - 'Côte d\'Ivoire', - 'Croatia', - 'Cuba', - 'Curaçao', - 'Cyprus', - 'Czech Republic', - 'Denmark', - 'Djibouti', - 'Dominica', - 'Dominican Republic', - 'Ecuador', - 'Egypt', - 'El Salvador', - 'Equatorial Guinea', - 'Eritrea', - 'Estonia', - 'Ethiopia', - 'Falkland Islands (Malvinas)', - 'Faroe Islands', - 'Fiji', - 'Finland', - 'France', - 'French Guiana', - 'French Polynesia', - 'French Southern Territories', - 'Gabon', - 'Gambia', - 'Georgia', - 'Germany', - 'Ghana', - 'Gibraltar', - 'Greece', - 'Greenland', - 'Grenada', - 'Guadeloupe', - 'Guam', - 'Guatemala', - 'Guernsey', - 'Guinea', - 'Guinea-Bissau', - 'Guyana', - 'Haiti', - 'Heard Island and McDonald Islands', - 'Holy See (Vatican City State)', - 'Honduras', - 'Hong Kong', - 'Hungary', - 'Iceland', - 'India', - 'Indonesia', - 'Iran, Islamic Republic of', - 'Iraq', - 'Ireland', - 'Isle of Man', - 'Israel', - 'Italy', - 'Jamaica', - 'Japan', - 'Jersey', - 'Jordan', - 'Kazakhstan', - 'Kenya', - 'Kiribati', - 'Korea, Democratic People\'s Republic of', - 'Korea, Republic of', - 'Kuwait', - 'Kyrgyzstan', - 'Lao People\'s Democratic Republic', - 'Latvia', - 'Lebanon', - 'Lesotho', - 'Liberia', - 'Libya', - 'Liechtenstein', - 'Lithuania', - 'Luxembourg', - 'Macao', - 'Macedonia, Republic Of', - 'Madagascar', - 'Malawi', - 'Malaysia', - 'Maldives', - 'Mali', - 'Malta', - 'Marshall Islands', - 'Martinique', - 'Mauritania', - 'Mauritius', - 'Mayotte', - 'Mexico', - 'Micronesia, Federated States of', - 'Moldova, Republic of', - 'Monaco', - 'Mongolia', - 'Montenegro', - 'Montserrat', - 'Morocco', - 'Mozambique', - 'Myanmar', - 'Namibia', - 'Nauru', - 'Nepal', - 'Netherlands', - 'New Caledonia', - 'New Zealand', - 'Nicaragua', - 'Niger', - 'Nigeria', - 'Niue', - 'Norfolk Island', - 'Northern Mariana Islands', - 'Norway', - 'Oman', - 'Pakistan', - 'Palau', - 'Palestinian Territory, Occupied', - 'Panama', - 'Papua New Guinea', - 'Paraguay', - 'Peru', - 'Philippines', - 'Pitcairn', - 'Poland', - 'Portugal', - 'Puerto Rico', - 'Qatar', - 'Reunion', - 'Romania', - 'Russian Federation', - 'Rwanda', - 'Saint Barthélemy', - 'Saint Helena, Ascension and Tristan da Cunha', - 'Saint Kitts and Nevis', - 'Saint Lucia', - 'Saint Martin (French Part)', - 'Saint Pierre and Miquelon', - 'Saint Vincent and the Grenadines', - 'Samoa', - 'San Marino', - 'Sao Tome and Principe', - 'Saudi Arabia', - 'Senegal', - 'Serbia', - 'Seychelles', - 'Sierra Leone', - 'Singapore', - 'Sint Maarten (Dutch Part)', - 'Slovakia', - 'Slovenia', - 'Solomon Islands', - 'Somalia', - 'South Africa', - 'South Georgia and the South Sandwich Islands', - 'South Sudan', - 'Spain', - 'Sri Lanka', - 'Sudan', - 'Suriname', - 'Svalbard and Jan Mayen', - 'Swaziland', - 'Sweden', - 'Switzerland', - 'Syrian Arab Republic', - 'Taiwan', - 'Tajikistan', - 'Tanzania, United Republic of', - 'Thailand', - 'Timor-Leste', - 'Togo', - 'Tokelau', - 'Tonga', - 'Trinidad and Tobago', - 'Tunisia', - 'Turkey', - 'Turkmenistan', - 'Turks and Caicos Islands', - 'Tuvalu', - 'Uganda', - 'Ukraine', - 'United Arab Emirates', - 'United Kingdom', - 'United States', - 'United States Minor Outlying Islands', - 'Uruguay', - 'Uzbekistan', - 'Vanuatu', - 'Venezuela', - 'Viet Nam', - 'Virgin Islands, British', - 'Virgin Islands, U.S.', - 'Wallis and Futuna', - 'Western Sahara', - 'Yemen', - 'Zambia', - 'Zimbabwe' -]; - -/* eslint-disable */ -export const POSTAL_CODE_PATTERNS = { - 'United States': '(\\d{5}([\\-]\\d{4})?)', - 'Australia': '[0-9]{4}', - 'Austria': '[0-9]{4}', - 'Belgium': '[0-9]{4}', - 'Brazil': '[0-9]{5}[\\-]?[0-9]{3}', - 'Canada': '^(?!.*[DFIOQU])[A-VXY][0-9][A-Z][ -]?[0-9][A-Z][0-9]$', - 'Denmark': '[0-9]{3,4}', - 'Faroe Islands': '[0-9]{3,4}', - 'Netherlands': '[1-9][0-9]{3}\\s?[a-zA-Z]{2}', - 'Germany': '[0-9]{5}', - 'Hungary': '[0-9]{4}', - 'Italy': '[0-9]{5}', - 'Japan': '\\d{3}-\\d{4}', - 'Luxembourg': '(L\\s*(-|—|–))\\s*?[\\d]{4}', - 'Poland': '[0-9]{2}\\-[0-9]{3}', - 'Spain': '((0[1-9]|5[0-2])|[1-4][0-9])[0-9]{3}', - 'Sweden': '\\d{3}\\s?\\d{2}', - 'United Kingdom': '[A-Za-z]{1,2}[0-9Rr][0-9A-Za-z]? [0-9][ABD-HJLNP-UW-Zabd-hjlnp-uw-z]{2}' -}; -/* eslint-enable */ - diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index e7fdfa78e3..ed9ca5123f 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -343,6 +343,10 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { private selectedValue = false; private selectedCallback: (selected: boolean) => void = () => {}; + resizableHandles = {} as any; + + resizeEnabled = true; + isFullscreen = false; isReference = false; @@ -387,6 +391,15 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { private gridsterItemComponentSubject = new Subject(); private gridsterItemComponentValue: GridsterItemComponentInterface; + private readonly aspectRatio: number; + + private heightValue: number; + private widthValue: number; + + private rowsValue: number; + private colsValue: number; + + get mobileHide(): boolean { return this.widgetLayout ? this.widgetLayout.mobileHide === true : false; } @@ -397,10 +410,96 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { set gridsterItemComponent(item: GridsterItemComponentInterface) { this.gridsterItemComponentValue = item; + + if (this.widgetLayout?.preserveAspectRatio) { + this.applyPreserveAspectRatio(item); + } + this.gridsterItemComponentSubject.next(this.gridsterItemComponentValue); this.gridsterItemComponentSubject.complete(); } + private applyPreserveAspectRatio(item: GridsterItemComponentInterface) { + this.resizableHandles.ne = false; + this.resizableHandles.sw = false; + this.resizableHandles.nw = false; + + const $item = item.$item; + + this.rowsValue = $item.rows; + this.colsValue = $item.cols; + + Object.defineProperty($item, 'rows', { + get: () => this.rowsValue, + set: v => { + if (this.rowsValue !== v) { + if (this.preserveAspectRatio) { + this.colsValue = v * this.aspectRatio; + } + this.rowsValue = v; + } + } + }); + + Object.defineProperty($item, 'cols', { + get: () => this.colsValue, + set: v => { + if (this.colsValue !== v) { + if (this.preserveAspectRatio) { + this.rowsValue = v / this.aspectRatio; + } + this.colsValue = v; + } + } + }); + + const resizable = item.resize; + + this.heightValue = resizable.height; + this.widthValue = resizable.width; + + const setItemHeight = resizable.setItemHeight.bind(resizable); + const setItemWidth = resizable.setItemWidth.bind(resizable); + resizable.setItemHeight = (height) => { + setItemHeight(height); + this.heightValue = height; + if (this.preserveAspectRatio) { + setItemWidth(height * this.aspectRatio); + } + }; + resizable.setItemWidth = (width) => { + setItemWidth(width); + this.widthValue = width; + if (this.preserveAspectRatio) { + setItemHeight(width / this.aspectRatio); + } + }; + + Object.defineProperty(resizable, 'height', { + get: () => this.heightValue, + set: v => { + if (this.heightValue !== v) { + if (this.preserveAspectRatio) { + this.widthValue = v * this.aspectRatio; + } + this.heightValue = v; + } + } + }); + + Object.defineProperty(resizable, 'width', { + get: () => this.widthValue, + set: v => { + if (this.widthValue !== v) { + if (this.preserveAspectRatio) { + this.heightValue = v / this.aspectRatio; + } + this.widthValue = v; + } + } + }); + } + get highlighted() { return this.highlightedValue; } @@ -434,6 +533,13 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { public widgetLayout?: WidgetLayout, private parentDashboard?: IDashboardComponent, private popoverComponent?: TbPopoverComponent) { + + if (isDefined(widgetLayout?.resizable)) { + this.resizeEnabled = widgetLayout.resizable; + } + if (widgetLayout?.preserveAspectRatio) { + this.aspectRatio = this.widgetLayout.sizeX / this.widgetLayout.sizeY; + } if (!widget.id) { widget.id = guid(); } @@ -603,30 +709,28 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { } } - @enumerable(true) - get cols(): number { - let res; - if (this.widgetLayout) { - res = this.widgetLayout.sizeX; + get preserveAspectRatio(): boolean { + if (!this.dashboard.isMobileSize && this.widgetLayout) { + return this.widgetLayout.preserveAspectRatio; } else { - res = this.widget.sizeX; + return false; } - return Math.floor(res); + } + + @enumerable(true) + get cols(): number { + return Math.floor(this.sizeX); } set cols(cols: number) { if (!this.dashboard.isMobileSize) { - if (this.widgetLayout) { - this.widgetLayout.sizeX = cols; - } else { - this.widget.sizeX = cols; - } + this.sizeX = cols; } } @enumerable(true) get rows(): number { - let res; + let res: number; if (this.dashboard.isMobileSize) { let mobileHeight; if (this.widgetLayout) { @@ -638,26 +742,50 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { if (mobileHeight) { res = mobileHeight; } else { - const sizeY = this.widgetLayout ? this.widgetLayout.sizeY : this.widget.sizeY; + const sizeY = this.sizeY; res = sizeY * 24 / this.dashboard.gridsterOpts.minCols; } } else { - if (this.widgetLayout) { - res = this.widgetLayout.sizeY; - } else { - res = this.widget.sizeY; - } + res = this.sizeY; } return Math.floor(res); } set rows(rows: number) { if (!this.dashboard.isMobileSize && !this.dashboard.autofillHeight) { - if (this.widgetLayout) { - this.widgetLayout.sizeY = rows; - } else { - this.widget.sizeY = rows; - } + this.sizeY = rows; + } + } + + get sizeX(): number { + if (this.widgetLayout) { + return this.widgetLayout.sizeX; + } else { + return this.widget.sizeX; + } + } + + set sizeX(sizeX: number) { + if (this.widgetLayout) { + this.widgetLayout.sizeX = sizeX; + } else { + this.widget.sizeX = sizeX; + } + } + + get sizeY(): number { + if (this.widgetLayout) { + return this.widgetLayout.sizeY; + } else { + return this.widget.sizeY; + } + } + + set sizeY(sizeY: number) { + if (this.widgetLayout) { + this.widgetLayout.sizeY = sizeY; + } else { + this.widget.sizeY = sizeY; } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html index 45b35c89c4..df43a595fe 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html @@ -23,6 +23,9 @@ {{ 'admin.oauth2.client-title-required' | translate }} + + {{ 'admin.oauth2.client-title-max-length' | translate }} +
@@ -67,7 +70,7 @@
- + admin.oauth2.advanced-settings @@ -162,7 +165,7 @@
admin.oauth2.scope - + {{scope}} diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts index cf60b3ed51..2662b97c65 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts @@ -103,6 +103,7 @@ export class ClientComponent extends EntityComponent, protected translate: TranslateService, @@ -126,7 +127,7 @@ export class ClientComponent extends EntityComponent { - if (domain === 'CUSTOM') { + if (domain === 'CUSTOM' && (this.createNewDialog || this.isEdit || this.isAdd)) { basicGroup.get('tenantNamePattern').enable(); } else { basicGroup.get('tenantNamePattern').disable(); @@ -345,7 +346,10 @@ export class ClientComponent extends EntityComponent('createdTime', 'common.created-time', this.datePipe, '170px'), diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts index 4bccc41dc7..7680a5d502 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts @@ -54,8 +54,6 @@ export class DomainTableConfigResolver implements Resolve('createdTime', 'common.created-time', this.datePipe, '170px'), diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-config.resolver.ts index 7585cfff46..625a0ad9b4 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app-table-config.resolver.ts @@ -55,8 +55,6 @@ export class MobileAppTableConfigResolver implements Resolve('createdTime', 'common.created-time', this.datePipe, '170px'), @@ -134,8 +132,8 @@ export class MobileAppTableConfigResolver implements Resolve 40) { - text = `${text.slice(0, 40)}…`; + if (text.length > 35) { + text = `${text.slice(0, 35)}…`; } return text; } diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.html index f280592925..afec8125c3 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.html @@ -25,6 +25,9 @@ {{ 'admin.oauth2.mobile-package-unique' | translate }} + + {{ 'admin.oauth2.mobile-package-max-length' | translate }} +
diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.ts index 2cca292fc3..9f90a96621 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/mobile-apps/mobile-app.component.ts @@ -54,7 +54,7 @@ export class MobileAppComponent extends EntityComponent { buildForm(entity: MobileAppInfo): UntypedFormGroup { return this.fb.group({ - pkgName: [entity?.pkgName ? entity.pkgName : '', [Validators.required]], + pkgName: [entity?.pkgName ? entity.pkgName : '', [Validators.required, Validators.maxLength(255)]], appSecret: [entity?.appSecret ? entity.appSecret : btoa(randomAlphanumeric(64)), [Validators.required, this.base64Format]], oauth2Enabled: isDefinedAndNotNull(entity?.oauth2Enabled) ? entity.oauth2Enabled : true, diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer.component.ts b/ui-ngx/src/app/modules/home/pages/customer/customer.component.ts index 8d04eb34c7..29ed565b6f 100644 --- a/ui-ngx/src/app/modules/home/pages/customer/customer.component.ts +++ b/ui-ngx/src/app/modules/home/pages/customer/customer.component.ts @@ -26,6 +26,7 @@ import { EntityTableConfig } from '@home/models/entity/entities-table-config.mod import { isDefinedAndNotNull } from '@core/utils'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { AuthState } from '@core/auth/auth.models'; +import { CountryData } from '@shared/models/country.models'; @Component({ selector: 'tb-customer', @@ -43,8 +44,9 @@ export class CustomerComponent extends ContactBasedComponent { @Inject('entity') protected entityValue: Customer, @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, protected fb: UntypedFormBuilder, - protected cd: ChangeDetectorRef) { - super(store, fb, entityValue, entitiesTableConfigValue, cd); + protected cd: ChangeDetectorRef, + protected countryData: CountryData) { + super(store, fb, entityValue, entitiesTableConfigValue, cd, countryData); } hideDelete() { diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts index e35d990ecb..9858850437 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts @@ -24,6 +24,7 @@ import { TranslateService } from '@ngx-translate/core'; import { ContactBasedComponent } from '../../components/entity/contact-based.component'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; import { isDefinedAndNotNull } from '@core/utils'; +import { CountryData } from '@shared/models/country.models'; @Component({ selector: 'tb-tenant', @@ -37,8 +38,9 @@ export class TenantComponent extends ContactBasedComponent { @Inject('entity') protected entityValue: TenantInfo, @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, protected fb: UntypedFormBuilder, - protected cd: ChangeDetectorRef) { - super(store, fb, entityValue, entitiesTableConfigValue, cd); + protected cd: ChangeDetectorRef, + protected countryData: CountryData) { + super(store, fb, entityValue, entitiesTableConfigValue, cd, countryData); } hideDelete() { diff --git a/ui-ngx/src/app/shared/components/contact.component.html b/ui-ngx/src/app/shared/components/contact.component.html index 059fd09c80..0f4038abe9 100644 --- a/ui-ngx/src/app/shared/components/contact.component.html +++ b/ui-ngx/src/app/shared/components/contact.component.html @@ -16,14 +16,10 @@ -->
- - contact.country - - - {{ country }} - - - + +
contact.city @@ -56,6 +52,7 @@ diff --git a/ui-ngx/src/app/shared/components/contact.component.ts b/ui-ngx/src/app/shared/components/contact.component.ts index c5a3daecf8..a344f8a715 100644 --- a/ui-ngx/src/app/shared/components/contact.component.ts +++ b/ui-ngx/src/app/shared/components/contact.component.ts @@ -16,7 +16,6 @@ import { Component, Input } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; -import { COUNTRIES } from '@home/models/contact.models'; @Component({ selector: 'tb-contact', @@ -29,6 +28,15 @@ export class ContactComponent { @Input() isEdit: boolean; - countries = COUNTRIES; + phoneInputDefaultCountry = 'US'; + constructor() { + } + + changeCountry(countryCode: string) { + this.phoneInputDefaultCountry = countryCode ?? 'US'; + setTimeout(() => { + this.parentForm.get('phone').setValue(this.parentForm.get('phone').value); + }); + } } diff --git a/ui-ngx/src/app/shared/components/country-autocomplete.component.html b/ui-ngx/src/app/shared/components/country-autocomplete.component.html new file mode 100644 index 0000000000..eadf630dcd --- /dev/null +++ b/ui-ngx/src/app/shared/components/country-autocomplete.component.html @@ -0,0 +1,58 @@ + + + {{ labelText }} + + + + + {{country.flag}} + + + +
+
+ contact.no-country-found +
+ + + {{ 'contact.no-country-matching' | translate: + {country: (searchText | truncate: true: 6: '...')} }} + + +
+
+
+ {{ autocompleteHint }} + + {{ requiredText }} + +
diff --git a/ui-ngx/src/app/shared/components/country-autocomplete.component.ts b/ui-ngx/src/app/shared/components/country-autocomplete.component.ts new file mode 100644 index 0000000000..7fc3a10bca --- /dev/null +++ b/ui-ngx/src/app/shared/components/country-autocomplete.component.ts @@ -0,0 +1,209 @@ +/// +/// 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, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { Country, CountryData } from '@shared/models/country.models'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { isNotEmptyStr } from '@core/utils'; +import { Observable, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; +import { SubscriptSizing } from '@angular/material/form-field'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { TranslateService } from '@ngx-translate/core'; + +interface CountrySearchData extends Country { + searchText?: string; +} + +@Component({ + selector: 'tb-country-autocomplete', + templateUrl: 'country-autocomplete.component.html', + providers: [ + CountryData, + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CountryAutocompleteComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CountryAutocompleteComponent), + multi: true + } + ] +}) +export class CountryAutocompleteComponent implements OnInit, ControlValueAccessor, Validator { + + @Input() + labelText = this.translate.instant('contact.country'); + + @Input() + requiredText = this.translate.instant('contact.country-required'); + + @Input() + autocompleteHint: string; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + required = false; + + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + + @ViewChild('countryInput', {static: true}) countryInput: ElementRef; + + @Output() + selectCountryCode = new EventEmitter(); + + countryFormGroup: FormGroup; + + searchText = ''; + + filteredCountries: Observable>; + + onTouched = () => { + }; + private propagateChange: (value: any) => void = () => { + }; + + private modelValue: Country; + + private allCountries: CountrySearchData[] = this.countryData.allCountries; + private initSearchData = false; + private dirty = false; + + constructor(private fb: FormBuilder, + private countryData: CountryData, + private translate: TranslateService) { + this.countryFormGroup = this.fb.group({ + country: [''] + }); + } + + ngOnInit(): void { + this.filteredCountries = this.countryFormGroup.get('country').valueChanges.pipe( + debounceTime(150), + tap(value => { + let modelValue: Country; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = value; + } + this.updateView(modelValue); + if (value === null) { + this.clear(); + } + }), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + distinctUntilChanged(), + switchMap(name => of(this.fetchCountries(name))), + share() + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.countryFormGroup.disable({emitEvent: false}); + } else { + this.countryFormGroup.enable({emitEvent: false}); + } + } + + validate(): ValidationErrors | null { + return this.countryFormGroup.valid ? null : { + countryFormGroup: false + }; + } + + writeValue(country: string) { + this.dirty = true; + + const findCountry = isNotEmptyStr(country) ? this.allCountries.find(item => item.name === country) : null; + + this.modelValue = findCountry || null; + this.countryFormGroup.get('country').patchValue(this.modelValue || '', { emitEvent: false }); + this.selectCountryCode.emit(this.modelValue ? this.modelValue.iso2 : null); + } + + displayCountryFn(country?: Country): string | undefined { + return country ? `${country.flag} ${country.name}` : undefined; + } + + onFocus() { + if (this.dirty) { + this.countryFormGroup.get('country').updateValueAndValidity({onlySelf: true}); + this.dirty = false; + } + } + + textIsNotEmpty(text: string): boolean { + return (text && text.length > 0); + } + + clear() { + this.countryFormGroup.get('country').patchValue('', {emitEvent: true}); + setTimeout(() => { + this.countryInput.nativeElement.blur(); + this.countryInput.nativeElement.focus(); + }, 0); + } + + private fetchCountries(searchText: string): Country[] { + this.searchText = searchText; + if (!this.initSearchData) { + this.allCountries.forEach(country => { + country.searchText = `${country.name} ${country.iso2}`.toLowerCase(); + }); + this.initSearchData = true; + } + if (isNotEmptyStr(searchText)) { + const filterValue = searchText.toLowerCase(); + return this.allCountries.filter(country => country.searchText.includes(filterValue)); + } + return this.allCountries; + } + + private updateView(value: Country | null) { + if (this.modelValue?.name !== value?.name) { + this.modelValue = value; + this.propagateChange(this.modelValue); + if (value) { + this.selectCountryCode.emit(value.iso2); + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html index 6512aa4e73..13cf1bd664 100644 --- a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html @@ -32,7 +32,7 @@ [matAutocomplete]="dashboardAutocomplete" [fxShow]="!useDashboardLink || !disabled || !selectDashboardFormGroup.get('dashboard').value"> - {{ displayDashboardFn(selectDashboardFormGroup.get('dashboard').value) }} + {{ displayDashboardFn(selectDashboardFormGroup.get('dashboard').value) | customTranslate }}