Browse Source

Merge remote-tracking branch 'upstream/master' into improvement/gateway-dashboard

pull/11071/head
Vladyslav_Prykhodko 2 years ago
parent
commit
1ff83eb4e5
  1. 5
      application/src/main/data/json/system/widget_bundles/cards.json
  2. 25
      application/src/main/data/json/system/widget_types/label___value_card.json
  3. 25
      application/src/main/data/json/system/widget_types/label_card.json
  4. 16
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java
  5. 273
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNodeTest.java
  6. 155
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbMsgCountNodeTest.java
  7. 176
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sns/TbSnsNodeTest.java
  8. 263
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNodeTest.java
  9. 249
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java
  10. 18
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  11. 88
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-card-basic-config.component.html
  12. 130
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-card-basic-config.component.ts
  13. 114
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-value-card-basic-config.component.html
  14. 191
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-value-card-basic-config.component.ts
  15. 30
      ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html
  16. 70
      ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.scss
  17. 170
      ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.ts
  18. 60
      ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.models.ts
  19. 34
      ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.html
  20. 80
      ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.scss
  21. 218
      ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.ts
  22. 80
      ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.models.ts
  23. 73
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-card-widget-settings.component.html
  24. 83
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-card-widget-settings.component.ts
  25. 81
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-value-card-widget-settings.component.html
  26. 108
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-value-card-widget-settings.component.ts
  27. 18
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  28. 14
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  29. 10
      ui-ngx/src/assets/locale/locale.constant-en_US.json

5
application/src/main/data/json/system/widget_bundles/cards.json

File diff suppressed because one or more lines are too long

25
application/src/main/data/json/system/widget_types/label___value_card.json

File diff suppressed because one or more lines are too long

25
application/src/main/data/json/system/widget_types/label_card.json

File diff suppressed because one or more lines are too long

16
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java

@ -35,8 +35,6 @@ import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.util.concurrent.ExecutionException;
import static org.thingsboard.common.util.DonAsynchron.withCallback;
@Slf4j
@ -81,34 +79,34 @@ public class TbSnsNode extends TbAbstractExternalNode {
}
@Override
public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException {
public void onMsg(TbContext ctx, TbMsg msg) {
var tbMsg = ackIfNeeded(ctx, msg);
withCallback(publishMessageAsync(ctx, tbMsg),
m -> tellSuccess(ctx, m),
t -> tellFailure(ctx, processException(ctx, tbMsg, t), t));
t -> tellFailure(ctx, processException(tbMsg, t), t));
}
private ListenableFuture<TbMsg> publishMessageAsync(TbContext ctx, TbMsg msg) {
return ctx.getExternalCallExecutor().executeAsync(() -> publishMessage(ctx, msg));
return ctx.getExternalCallExecutor().executeAsync(() -> publishMessage(msg));
}
private TbMsg publishMessage(TbContext ctx, TbMsg msg) {
private TbMsg publishMessage(TbMsg msg) {
String topicArn = TbNodeUtils.processPattern(this.config.getTopicArnPattern(), msg);
PublishRequest publishRequest = new PublishRequest()
.withTopicArn(topicArn)
.withMessage(msg.getData());
PublishResult result = this.snsClient.publish(publishRequest);
return processPublishResult(ctx, msg, result);
return processPublishResult(msg, result);
}
private TbMsg processPublishResult(TbContext ctx, TbMsg origMsg, PublishResult result) {
private TbMsg processPublishResult(TbMsg origMsg, PublishResult result) {
TbMsgMetaData metaData = origMsg.getMetaData().copy();
metaData.putValue(MESSAGE_ID, result.getMessageId());
metaData.putValue(REQUEST_ID, result.getSdkResponseMetadata().getRequestId());
return TbMsg.transformMsgMetadata(origMsg, metaData);
}
private TbMsg processException(TbContext ctx, TbMsg origMsg, Throwable t) {
private TbMsg processException(TbMsg origMsg, Throwable t) {
TbMsgMetaData metaData = origMsg.getMetaData().copy();
metaData.putValue(ERROR, t.getClass() + ": " + t.getMessage());
return TbMsg.transformMsgMetadata(origMsg, metaData);

273
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNodeTest.java

@ -0,0 +1,273 @@
/**
* 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.
*/
package org.thingsboard.rule.engine.action;
import com.google.common.util.concurrent.FutureCallback;
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;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.EmptyNodeConfiguration;
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityViewId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.data.objects.AttributesEntityView;
import org.thingsboard.server.common.data.objects.TelemetryEntityView;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.entityview.EntityViewService;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_DELETED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_UPDATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.INACTIVITY_EVENT;
import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST;
@ExtendWith(MockitoExtension.class)
public class TbCopyAttributesToEntityViewNodeTest {
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("9fdb1f05-dc66-4960-9263-ae195f1b4533"));
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("1d453dc9-9333-476a-a51f-093cf2176e59"));
private final EntityViewId ENTITY_VIEW_ID = new EntityViewId(UUID.fromString("65636806-453d-4bb4-b513-92b833970753"));
private final AttributesEntityView CLIENT_ATTRIBUTES = new AttributesEntityView(List.of("clientAttribute1"), Collections.emptyList(), Collections.emptyList());
private final AttributesEntityView SERVER_ATTRIBUTES = new AttributesEntityView(Collections.emptyList(), List.of("serverAttribute1"), Collections.emptyList());
private final AttributesEntityView SHARED_ATTRIBUTES = new AttributesEntityView(Collections.emptyList(), Collections.emptyList(), List.of("sharedAttribute1"));
private final TelemetryEntityView CLIENT_TELEMETRY_ENTITY_VIEW = new TelemetryEntityView(Collections.emptyList(), CLIENT_ATTRIBUTES);
private final TelemetryEntityView SERVER_TELEMETRY_ENTITY_VIEW = new TelemetryEntityView(Collections.emptyList(), SERVER_ATTRIBUTES);
private final TelemetryEntityView SHARED_TELEMETRY_ENTITY_VIEW = new TelemetryEntityView(Collections.emptyList(), SHARED_ATTRIBUTES);
private final long ENTITY_VIEW_START_TS = Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli();
private final long ENTITY_VIEW_END_TS = Instant.now().plus(1, ChronoUnit.DAYS).toEpochMilli();
private TbCopyAttributesToEntityViewNode node;
@Mock
private TbContext ctxMock;
@Mock
private EntityViewService entityViewServiceMock;
@Mock
private RuleEngineTelemetryService telemetryServiceMock;
@BeforeEach
void setUp() throws TbNodeException {
node = new TbCopyAttributesToEntityViewNode();
var config = new EmptyNodeConfiguration().defaultConfiguration();
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config));
node.init(ctxMock, configuration);
}
@Test
public void givenExistingClientAttributes_whenOnMsg_thenCopyAttributesToView() {
EntityView entityView = getEntityView(CLIENT_TELEMETRY_ENTITY_VIEW);
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_ATTRIBUTES_REQUEST, DEVICE_ID,
new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SERVER_SCOPE.name())),
"{\"clientAttribute1\": 100, \"clientAttribute2\": \"value2\"}");
mockEntityViewLookup(entityView);
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock);
doAnswer(invocation -> {
FutureCallback<Void> callback = invocation.getArgument(4);
callback.onSuccess(null);
return null;
}).when(telemetryServiceMock).saveAndNotify(any(), any(), any(AttributeScope.class), anyList(), any(FutureCallback.class));
TbMsg newMsg = TbMsg.newMsg(msg, msg.getQueueName(), msg.getRuleChainId(), msg.getRuleNodeId());
// TODO: use newMsg() with any(TbMsgType.class), replace in other tests as well.
doAnswer(invocation -> newMsg).when(ctxMock).newMsg(any(), any(String.class), any(), any(), any(), any());
node.onMsg(ctxMock, msg);
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID));
ArgumentCaptor<List<AttributeKvEntry>> filteredAttributesCaptor = ArgumentCaptor.forClass(List.class);
verify(telemetryServiceMock).saveAndNotify(eq(TENANT_ID), eq(ENTITY_VIEW_ID), eq(AttributeScope.CLIENT_SCOPE),
filteredAttributesCaptor.capture(), any(FutureCallback.class));
List<AttributeKvEntry> filteredAttributesCaptorValue = filteredAttributesCaptor.getValue();
assertThat(filteredAttributesCaptorValue.size()).isEqualTo(1);
assertThat(filteredAttributesCaptorValue.get(0).getKey()).isEqualTo("clientAttribute1");
assertThat(filteredAttributesCaptorValue.get(0).getValue()).isEqualTo(100L);
verify(ctxMock).ack(eq(msg));
verify(ctxMock).enqueueForTellNext(eq(newMsg), eq(TbNodeConnectionType.SUCCESS));
verifyNoMoreInteractions(ctxMock, entityViewServiceMock, telemetryServiceMock);
}
@Test
public void givenExistingServerAttributesAndMsgTypeAttributesDeleted_whenOnMsg_thenDeleteAttributesFromView() {
EntityView entityView = getEntityView(SERVER_TELEMETRY_ENTITY_VIEW);
TbMsg msg = TbMsg.newMsg(
ATTRIBUTES_DELETED, DEVICE_ID, new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SERVER_SCOPE.name())),
"{\"attributes\": [\"serverAttribute1\"]}");
mockEntityViewLookup(entityView);
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock);
doAnswer(invocation -> {
FutureCallback<Void> callback = invocation.getArgument(4);
callback.onSuccess(null);
return null;
}).when(telemetryServiceMock).deleteAndNotify(any(), any(), any(AttributeScope.class), anyList(), any(FutureCallback.class));
TbMsg newMsg = TbMsg.newMsg(msg, msg.getQueueName(), msg.getRuleChainId(), msg.getRuleNodeId());
doAnswer(invocation -> newMsg).when(ctxMock).newMsg(any(), any(String.class), any(), any(), any(), any());
node.onMsg(ctxMock, msg);
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID));
ArgumentCaptor<List<String>> filteredAttributesCaptor = ArgumentCaptor.forClass(List.class);
verify(telemetryServiceMock).deleteAndNotify(eq(TENANT_ID), eq(ENTITY_VIEW_ID), eq(AttributeScope.SERVER_SCOPE), filteredAttributesCaptor.capture(), any(FutureCallback.class));
List<String> filteredAttributesCaptorValue = filteredAttributesCaptor.getValue();
assertThat(filteredAttributesCaptorValue.size()).isEqualTo(1);
assertThat(filteredAttributesCaptorValue.get(0)).isEqualTo("serverAttribute1");
verify(ctxMock).ack(eq(msg));
verify(ctxMock).enqueueForTellNext(eq(newMsg), eq(TbNodeConnectionType.SUCCESS));
verifyNoMoreInteractions(ctxMock, entityViewServiceMock, telemetryServiceMock);
}
@Test
public void givenNonMatchedSharedAttributesAndMsgTypeIsAttributesDeleted_whenOnMsg_thenNoAttributesDeleteFromView() {
EntityView entityView = getEntityView(SHARED_TELEMETRY_ENTITY_VIEW);
TbMsg msg = TbMsg.newMsg(
TbMsgType.ATTRIBUTES_DELETED, DEVICE_ID, new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SHARED_SCOPE.name())),
"{\"attributes\": [\"anotherAttribute\"]}");
mockEntityViewLookup(entityView);
node.onMsg(ctxMock, msg);
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID));
verify(ctxMock).ack(eq(msg));
verifyNoMoreInteractions(ctxMock, entityViewServiceMock);
}
@Test
public void givenNonMatchedAttributesAndMsgTypeIsPostAttributesRequest_whenOnMsg_thenCopyNoAttributesToView() {
EntityView entityView = getEntityView(CLIENT_TELEMETRY_ENTITY_VIEW);
TbMsg msg = TbMsg.newMsg(
TbMsgType.POST_ATTRIBUTES_REQUEST, DEVICE_ID, new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SERVER_SCOPE.name())),
"{\"clientAttribute2\": \"value2\"}");
mockEntityViewLookup(entityView);
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock);
doAnswer(invocation -> {
FutureCallback<Void> callback = invocation.getArgument(4);
callback.onSuccess(null);
return null;
}).when(telemetryServiceMock).saveAndNotify(any(), any(), any(AttributeScope.class), anyList(), any(FutureCallback.class));
TbMsg newMsg = TbMsg.newMsg(msg, msg.getQueueName(), msg.getRuleChainId(), msg.getRuleNodeId());
doAnswer(invocation -> newMsg).when(ctxMock).newMsg(any(), any(String.class), any(), any(), any(), any());
node.onMsg(ctxMock, msg);
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID));
verify(telemetryServiceMock).saveAndNotify(eq(TENANT_ID), eq(ENTITY_VIEW_ID), eq(AttributeScope.CLIENT_SCOPE), eq(Collections.emptyList()), any(FutureCallback.class));
verify(ctxMock).ack(eq(msg));
verify(ctxMock).enqueueForTellNext(eq(newMsg), eq(TbNodeConnectionType.SUCCESS));
verifyNoMoreInteractions(ctxMock, entityViewServiceMock, telemetryServiceMock);
}
@Test
public void givenAttributesValidityPeriodOutOfStartDateAndEndDate_whenOnMsg_thenDoNothing() {
EntityView entityView = getEntityView(
SERVER_TELEMETRY_ENTITY_VIEW,
Instant.now().minus(2, ChronoUnit.DAYS).toEpochMilli(),
Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli()
);
mockEntityViewLookup(entityView);
TbMsg msg = TbMsg.newMsg(
ATTRIBUTES_DELETED, DEVICE_ID, new TbMsgMetaData(Map.of(DataConstants.SCOPE, AttributeScope.SERVER_SCOPE.name())),
"{\"attributes\": [\"serverAttribute1\"]}");
node.onMsg(ctxMock, msg);
verify(entityViewServiceMock).findEntityViewsByTenantIdAndEntityIdAsync(eq(TENANT_ID), eq(DEVICE_ID));
verify(ctxMock).ack(eq(msg));
verifyNoMoreInteractions(ctxMock, entityViewServiceMock);
}
@ParameterizedTest
@EnumSource(TbMsgType.class)
public void givenMsgTypeAndEmptyMetadata_whenOnMsg_thenVerifyFailureMsg(TbMsgType msgType) {
TbMsg msg = TbMsg.newMsg(msgType, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture());
if (msg.isTypeOneOf(ATTRIBUTES_UPDATED, ATTRIBUTES_DELETED,
ACTIVITY_EVENT, INACTIVITY_EVENT, POST_ATTRIBUTES_REQUEST)) {
assertThat(throwableCaptor.getValue()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Message metadata is empty");
return;
}
assertThat(throwableCaptor.getValue()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported msg type [" + msgType + "]");
verifyNoMoreInteractions(ctxMock);
}
private EntityView getEntityView(TelemetryEntityView attributesEntityView, long startTimeMs, long endTimeMs) {
EntityView entityView = new EntityView(ENTITY_VIEW_ID);
entityView.setStartTimeMs(startTimeMs);
entityView.setEndTimeMs(endTimeMs);
entityView.setKeys(attributesEntityView);
return entityView;
}
private EntityView getEntityView(TelemetryEntityView attributesEntityView) {
return getEntityView(attributesEntityView, ENTITY_VIEW_START_TS, ENTITY_VIEW_END_TS);
}
private void mockEntityViewLookup(EntityView entityView) {
when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock);
when(ctxMock.getTenantId()).thenReturn(TENANT_ID);
when(entityViewServiceMock.findEntityViewsByTenantIdAndEntityIdAsync(any(), any()))
.thenReturn(Futures.immediateFuture(List.of(entityView)));
}
}

155
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbMsgCountNodeTest.java

@ -0,0 +1,155 @@
/**
* 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.
*/
package org.thingsboard.rule.engine.action;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.stubbing.Answer;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.util.ArrayList;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willAnswer;
import static org.mockito.Mockito.times;
@Slf4j
@ExtendWith(MockitoExtension.class)
public class TbMsgCountNodeTest {
private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("ee682a85-7f5a-4182-91bc-46e555138fe2"));
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("1b21c7cc-0c9e-4ab1-b867-99451599e146"));
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("04dfbd38-10e5-47b7-925f-11e795db89e1"));
private final ThingsBoardThreadFactory factory = ThingsBoardThreadFactory.forName("msg-count-node-test");
private final TbMsg tickMsg = TbMsg.newMsg(TbMsgType.MSG_COUNT_SELF_MSG, RULE_NODE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING);
private ScheduledExecutorService executorService;
private TbMsgCountNode node;
private TbMsgCountNodeConfiguration config;
@Mock
private TbContext ctxMock;
@BeforeEach
public void setUp() {
node = new TbMsgCountNode();
config = new TbMsgCountNodeConfiguration().defaultConfiguration();
executorService = Executors.newSingleThreadScheduledExecutor(factory);
}
@AfterEach
public void tearDown() {
if (executorService != null) {
executorService.shutdownNow();
}
node.destroy();
}
@Test
public void verifyDefaultConfig() {
assertThat(config.getInterval()).isEqualTo(1);
assertThat(config.getTelemetryPrefix()).isEqualTo("messageCount");
}
@Test
public void givenIncomingMsgs_whenOnMsg_thenSendsMsgWithMsgCount() throws TbNodeException, InterruptedException {
// GIVEN
int msgCount = 100;
var awaitTellSelfLatch = new CountDownLatch(1);
var currentMsgNumber = new AtomicInteger(0);
var msgWithCounterSent = new AtomicBoolean(false);
willAnswer((Answer<Void>) invocationOnMock -> {
executorService.schedule(() -> {
TbMsg tickMsg = invocationOnMock.getArgument(0);
msgWithCounterSent.set(true);
node.onMsg(ctxMock, tickMsg);
awaitTellSelfLatch.countDown();
}, config.getInterval(), TimeUnit.SECONDS);
return null;
}).given(ctxMock).tellSelf(any(TbMsg.class), anyLong());
given(ctxMock.getTenantId()).willReturn(TENANT_ID);
given(ctxMock.getServiceId()).willReturn("tb-rule-engine");
given(ctxMock.getSelfId()).willReturn(RULE_NODE_ID);
given(ctxMock.newMsg(null, TbMsgType.MSG_COUNT_SELF_MSG, RULE_NODE_ID, null, TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING)).willReturn(tickMsg);
// WHEN
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var expectedProcessedMsgs = new ArrayList<TbMsg>();
for (int i = 0; i < msgCount; i++) {
var msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING);
if (msgWithCounterSent.get()) {
break;
}
node.onMsg(ctxMock, msg);
expectedProcessedMsgs.add(msg);
currentMsgNumber.getAndIncrement();
}
awaitTellSelfLatch.await();
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
then(ctxMock).should(times(currentMsgNumber.get())).ack(msgCaptor.capture());
var actualProcessedMsgs = msgCaptor.getAllValues();
assertThat(actualProcessedMsgs).hasSize(expectedProcessedMsgs.size());
assertThat(actualProcessedMsgs).isNotEmpty();
assertThat(actualProcessedMsgs).containsExactlyInAnyOrderElementsOf(expectedProcessedMsgs);
ArgumentCaptor<TbMsg> msgWithCounterCaptor = ArgumentCaptor.forClass(TbMsg.class);
then(ctxMock).should().enqueueForTellNext(msgWithCounterCaptor.capture(), eq(TbNodeConnectionType.SUCCESS));
TbMsg resultedMsg = msgWithCounterCaptor.getValue();
String expectedData = "{\"messageCount_tb-rule-engine\":" + currentMsgNumber + "}";
TbMsg expectedMsg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, TENANT_ID, TbMsgMetaData.EMPTY, expectedData);
assertThat(resultedMsg).usingRecursiveComparison()
.ignoringFields("id", "ts", "ctx", "metaData")
.isEqualTo(expectedMsg);
Map<String, String> actualMetadata = resultedMsg.getMetaData().getData();
assertThat(actualMetadata).hasFieldOrProperty("delta");
}
}

176
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sns/TbSnsNodeTest.java

@ -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.
*/
package org.thingsboard.rule.engine.aws.sns;
import com.amazonaws.ResponseMetadata;
import com.amazonaws.services.sns.AmazonSNS;
import com.amazonaws.services.sns.model.PublishRequest;
import com.amazonaws.services.sns.model.PublishResult;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.ListeningExecutor;
import org.thingsboard.rule.engine.TestDbCallbackExecutor;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.mock;
import static org.mockito.BDDMockito.never;
import static org.mockito.BDDMockito.verifyNoMoreInteractions;
@ExtendWith(MockitoExtension.class)
class TbSnsNodeTest {
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("fccfdf2e-6a88-4a94-81dd-5cbb557019cf"));
private final ListeningExecutor executor = new TestDbCallbackExecutor();
private TbSnsNode node;
private TbSnsNodeConfiguration config;
@Mock
private TbContext ctxMock;
@Mock
private AmazonSNS snsClientMock;
@Mock
private PublishResult publishResultMock;
@Mock
private ResponseMetadata responseMetadataMock;
@BeforeEach
void setUp() {
node = new TbSnsNode();
config = new TbSnsNodeConfiguration().defaultConfiguration();
ReflectionTestUtils.setField(node, "snsClient", snsClientMock);
ReflectionTestUtils.setField(node, "config", config);
}
@Test
void verifyDefaultConfig() {
assertThat(config.getTopicArnPattern()).isEqualTo("arn:aws:sns:us-east-1:123456789012:MyNewTopic");
assertThat(config.getAccessKeyId()).isNull();
assertThat(config.getSecretAccessKey()).isNull();
assertThat(config.getRegion()).isEqualTo("us-east-1");
}
@ParameterizedTest
@MethodSource
void givenForceAckIsTrueAndTopicNamePattern_whenOnMsg_thenEnqueueForTellNext(String topicName, TbMsgMetaData metaData, String data) {
ReflectionTestUtils.setField(node, "forceAck", true);
config.setAccessKeyId("accessKeyId");
config.setSecretAccessKey("secretAccessKey");
config.setTopicArnPattern(topicName);
String messageId = "msgId-1d186a16-80c7-44b3-a245-a1fc835f20c7";
String requestId = "reqId-bef0799b-dde9-4aa0-855b-86bbafaeaf31";
given(ctxMock.getExternalCallExecutor()).willReturn(executor);
given(snsClientMock.publish(any(PublishRequest.class))).willReturn(publishResultMock);
given(publishResultMock.getMessageId()).willReturn(messageId);
given(publishResultMock.getSdkResponseMetadata()).willReturn(responseMetadataMock);
given(responseMetadataMock.getRequestId()).willReturn(requestId);
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, data);
node.onMsg(ctxMock, msg);
then(ctxMock).should().ack(msg);
PublishRequest publishRequest = new PublishRequest()
.withTopicArn(TbNodeUtils.processPattern(topicName, msg))
.withMessage(data);
then(snsClientMock).should().publish(publishRequest);
ArgumentCaptor<TbMsg> actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class);
then(ctxMock).should().enqueueForTellNext(actualMsgCaptor.capture(), eq(TbNodeConnectionType.SUCCESS));
TbMsg actualMsg = actualMsgCaptor.getValue();
assertThat(actualMsg)
.usingRecursiveComparison()
.ignoringFields("metaData", "ctx")
.isEqualTo(msg);
assertThat(actualMsg.getMetaData().getData())
.hasFieldOrPropertyWithValue("messageId", messageId)
.hasFieldOrPropertyWithValue("requestId", requestId);
verifyNoMoreInteractions(ctxMock, snsClientMock, publishResultMock, responseMetadataMock);
}
private static Stream<Arguments> givenForceAckIsTrueAndTopicNamePattern_whenOnMsg_thenEnqueueForTellNext() {
return Stream.of(
Arguments.of("arn:aws:sns:us-east-1:123456789012:NewTopic", TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT),
Arguments.of("arn:aws:sns:us-east-1:123456789012:$[msgTopicName]", TbMsgMetaData.EMPTY, "{\"msgTopicName\":\"msg-topic-name\"}"),
Arguments.of("arn:aws:sns:us-east-1:123456789012:${mdTopicName}", new TbMsgMetaData(Map.of("mdTopicName", "md-topic-name")), TbMsg.EMPTY_JSON_OBJECT)
);
}
@Test
void givenForceAckIsFalseAndErrorOccursDuringProcessingRequest_whenOnMsg_thenTellFailure() {
ReflectionTestUtils.setField(node, "forceAck", false);
ListeningExecutor listeningExecutor = mock(ListeningExecutor.class);
given(ctxMock.getExternalCallExecutor()).willReturn(listeningExecutor);
String errorMsg = "Something went wrong";
ListenableFuture<TbMsg> failedFuture = Futures.immediateFailedFuture(new RuntimeException(errorMsg));
given(listeningExecutor.executeAsync(any(Callable.class))).willReturn(failedFuture);
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
then(ctxMock).should(never()).enqueueForTellNext(any(), any(String.class));
ArgumentCaptor<TbMsg> actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
then(ctxMock).should().tellFailure(actualMsgCaptor.capture(), throwableCaptor.capture());
TbMsg actualMsg = actualMsgCaptor.getValue();
assertThat(actualMsg)
.usingRecursiveComparison()
.ignoringFields("metaData", "ctx")
.isEqualTo(msg);
assertThat(actualMsg.getMetaData().getData())
.hasFieldOrPropertyWithValue("error", RuntimeException.class + ": " + errorMsg);
assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class).hasMessage(errorMsg);
verifyNoMoreInteractions(ctxMock, snsClientMock);
}
@Test
void givenSnsClientIsNotNull_whenDestroy_thenShutdown() {
node.destroy();
then(snsClientMock).should().shutdown();
}
@Test
void givenSnsClientIsNull_whenDestroy_thenVerifyNoInteractions() {
ReflectionTestUtils.setField(node, "snsClient", null);
node.destroy();
then(snsClientMock).shouldHaveNoInteractions();
}
}

263
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNodeTest.java

@ -0,0 +1,263 @@
/**
* 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.
*/
package org.thingsboard.rule.engine.aws.sqs;
import com.amazonaws.ResponseMetadata;
import com.amazonaws.services.sqs.AmazonSQS;
import com.amazonaws.services.sqs.model.MessageAttributeValue;
import com.amazonaws.services.sqs.model.SendMessageRequest;
import com.amazonaws.services.sqs.model.SendMessageResult;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.ListeningExecutor;
import org.thingsboard.rule.engine.TestDbCallbackExecutor;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.rule.engine.aws.sqs.TbSqsNodeConfiguration.QueueType;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.mock;
import static org.mockito.BDDMockito.never;
import static org.mockito.BDDMockito.verifyNoMoreInteractions;
@ExtendWith(MockitoExtension.class)
class TbSqsNodeTest {
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("764de824-929f-4114-95ea-0ea0401ffa3d"));
private final ListeningExecutor executor = new TestDbCallbackExecutor();
private final String messageId = "msgId-1d186a16-80c7-44b3-a245-a1fc835f20c7";
private final String requestId = "reqId-bef0799b-dde9-4aa0-855b-86bbafaeaf31";
private TbSqsNode node;
private TbSqsNodeConfiguration config;
@Mock
private TbContext ctxMock;
@Mock
private AmazonSQS sqsClientMock;
@Mock
private SendMessageResult sendMessageResultMock;
@Mock
private ResponseMetadata responseMetadataMock;
@BeforeEach
void setUp() {
node = new TbSqsNode();
config = new TbSqsNodeConfiguration().defaultConfiguration();
ReflectionTestUtils.setField(node, "sqsClient", sqsClientMock);
ReflectionTestUtils.setField(node, "config", config);
}
@Test
void verifyDefaultConfig() {
assertThat(config.getQueueType()).isEqualTo(QueueType.STANDARD);
assertThat(config.getQueueUrlPattern()).isEqualTo("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue-name");
assertThat(config.getDelaySeconds()).isEqualTo(0);
assertThat(config.getMessageAttributes()).isEqualTo(Collections.emptyMap());
assertThat(config.getAccessKeyId()).isNull();
assertThat(config.getSecretAccessKey()).isNull();
assertThat(config.getRegion()).isEqualTo("us-east-1");
}
@ParameterizedTest
@MethodSource
void givenQueueUrlPatternsAndQueueTypeIsFifo_whenOnMsg_thenVerifyRequest(String queueUrl, TbMsgMetaData metaData, String data) {
config.setQueueType(QueueType.FIFO);
config.setQueueUrlPattern(queueUrl);
mockSendingMsgRequest();
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, data);
node.onMsg(ctxMock, msg);
SendMessageRequest sendMsgRequest = new SendMessageRequest()
.withQueueUrl(TbNodeUtils.processPattern(queueUrl, msg))
.withMessageBody(data)
.withMessageDeduplicationId(msg.getId().toString())
.withMessageGroupId(DEVICE_ID.toString());
then(sqsClientMock).should().sendMessage(sendMsgRequest);
}
private static Stream<Arguments> givenQueueUrlPatternsAndQueueTypeIsFifo_whenOnMsg_thenVerifyRequest() {
return Stream.of(
Arguments.of(
"https://sqs.us-east-1.amazonaws.com/123456789012/new-queue-name",
TbMsgMetaData.EMPTY,
TbMsg.EMPTY_JSON_OBJECT),
Arguments.of(
"https://sqs.us-east-1.amazonaws.com/123456789012/$[msgQueueName]",
TbMsgMetaData.EMPTY,
"{\"msgQueueName\":\"msg-queue-name\"}"),
Arguments.of(
"https://sqs.us-east-1.amazonaws.com/123456789012/${mdQueueName}",
new TbMsgMetaData(Map.of("mdQueueName", "md-queue-name")),
TbMsg.EMPTY_JSON_OBJECT)
);
}
@ParameterizedTest
@MethodSource
void givenMsgAttributesPatternsAndQueueTypeIsStandard_whenOnMsg_thenVerifyRequest(TbMsgMetaData metaData, String data,
Map<String, String> attributes) {
config.setMessageAttributes(attributes);
mockSendingMsgRequest();
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, data);
node.onMsg(ctxMock, msg);
Map<String, MessageAttributeValue> messageAttributes = new HashMap<>();
this.config.getMessageAttributes().forEach((k, v) -> {
String name = TbNodeUtils.processPattern(k, msg);
String val = TbNodeUtils.processPattern(v, msg);
messageAttributes.put(name, new MessageAttributeValue().withDataType("String").withStringValue(val));
});
SendMessageRequest sendMsgRequest = new SendMessageRequest()
.withQueueUrl(config.getQueueUrlPattern())
.withMessageBody(data)
.withMessageAttributes(messageAttributes)
.withDelaySeconds(config.getDelaySeconds());
then(sqsClientMock).should().sendMessage(sendMsgRequest);
}
private static Stream<Arguments> givenMsgAttributesPatternsAndQueueTypeIsStandard_whenOnMsg_thenVerifyRequest() {
return Stream.of(
Arguments.of(TbMsgMetaData.EMPTY,
TbMsg.EMPTY_JSON_OBJECT,
Map.of("attributeName", "attributeValue")),
Arguments.of(TbMsgMetaData.EMPTY,
"{\"msgAttrNamePattern\":\"msgAttrName\",\"msgAttrValuePattern\":\"msgAttrValue\"}",
Map.of("$[msgAttrNamePattern]", "$[msgAttrValuePattern]")),
Arguments.of(new TbMsgMetaData(Map.of("mdAttrNamePattern", "mdAttrName", "mdAttrValuePattern", "mdAttrValue")),
TbMsg.EMPTY_JSON_OBJECT,
Map.of("${mdAttrNamePattern}", "${mdAttrValuePattern}"))
);
}
@Test
void givenForceAckIsTrueAndMsgResultContainsBodyAndAttributesAndNumber_whenOnMsg_thenEnqueueForTellNext() {
ReflectionTestUtils.setField(node, "forceAck", true);
String messageBodyMd5 = "msgBodyMd5-55fb8ba2-2b71-4673-a82a-969756764761";
String messageAttributesMd5 = "msgAttrMd5-e3ba3eef-52ae-436a-bec1-0c2c2252d1f1";
String sequenceNumber = "seqNum-bb5ddce0-cf4e-4295-b015-524bdb6a332f";
mockSendingMsgRequest();
given(sendMessageResultMock.getMD5OfMessageBody()).willReturn(messageBodyMd5);
given(sendMessageResultMock.getMD5OfMessageAttributes()).willReturn(messageAttributesMd5);
given(sendMessageResultMock.getSequenceNumber()).willReturn(sequenceNumber);
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
then(ctxMock).should().ack(msg);
SendMessageRequest sendMsgRequest = new SendMessageRequest()
.withQueueUrl(TbNodeUtils.processPattern(config.getQueueUrlPattern(), msg))
.withMessageBody(msg.getData())
.withDelaySeconds(config.getDelaySeconds());
then(sqsClientMock).should().sendMessage(sendMsgRequest);
ArgumentCaptor<TbMsg> actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class);
then(ctxMock).should().enqueueForTellNext(actualMsgCaptor.capture(), eq(TbNodeConnectionType.SUCCESS));
TbMsg actualMsg = actualMsgCaptor.getValue();
assertThat(actualMsg)
.usingRecursiveComparison()
.ignoringFields("metaData", "ctx")
.isEqualTo(msg);
assertThat(actualMsg.getMetaData().getData())
.hasFieldOrPropertyWithValue("messageId", messageId)
.hasFieldOrPropertyWithValue("requestId", requestId)
.hasFieldOrPropertyWithValue("messageBodyMd5", messageBodyMd5)
.hasFieldOrPropertyWithValue("messageAttributesMd5", messageAttributesMd5)
.hasFieldOrPropertyWithValue("sequenceNumber", sequenceNumber);
verifyNoMoreInteractions(ctxMock, sqsClientMock, sendMessageResultMock, responseMetadataMock);
}
@Test
void givenForceAckIsFalseAndErrorOccursDuringProcessingRequest_whenOnMsg_thenTellFailure() {
ReflectionTestUtils.setField(node, "forceAck", false);
ListeningExecutor listeningExecutor = mock(ListeningExecutor.class);
given(ctxMock.getExternalCallExecutor()).willReturn(listeningExecutor);
String errorMsg = "Something went wrong";
ListenableFuture<TbMsg> failedFuture = Futures.immediateFailedFuture(new RuntimeException(errorMsg));
given(listeningExecutor.executeAsync(any(Callable.class))).willReturn(failedFuture);
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
then(ctxMock).should(never()).enqueueForTellNext(any(), any(String.class));
ArgumentCaptor<TbMsg> actualMsgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
then(ctxMock).should().tellFailure(actualMsgCaptor.capture(), throwableCaptor.capture());
TbMsg actualMsg = actualMsgCaptor.getValue();
assertThat(actualMsg)
.usingRecursiveComparison()
.ignoringFields("metaData", "ctx")
.isEqualTo(msg);
assertThat(actualMsg.getMetaData().getData())
.hasFieldOrPropertyWithValue("error", RuntimeException.class + ": " + errorMsg);
assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class).hasMessage(errorMsg);
verifyNoMoreInteractions(ctxMock, sqsClientMock);
}
@Test
void givenSqsClientIsNotNull_whenDestroy_thenShutdown() {
node.destroy();
then(sqsClientMock).should().shutdown();
}
@Test
void givenSqsClientIsNull_whenDestroy_thenVerifyNoInteractions() {
ReflectionTestUtils.setField(node, "sqsClient", null);
node.destroy();
then(sqsClientMock).shouldHaveNoInteractions();
}
private void mockSendingMsgRequest() {
given(ctxMock.getExternalCallExecutor()).willReturn(executor);
given(sqsClientMock.sendMessage(any(SendMessageRequest.class))).willReturn(sendMessageResultMock);
given(sendMessageResultMock.getMessageId()).willReturn(messageId);
given(sendMessageResultMock.getSdkResponseMetadata()).willReturn(responseMetadataMock);
given(responseMetadataMock.getRequestId()).willReturn(requestId);
}
}

249
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java

@ -0,0 +1,249 @@
/**
* 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.
*/
package org.thingsboard.rule.engine.telemetry;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.adaptor.JsonConverter;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
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.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class TbMsgTimeseriesNodeTest {
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("c8f34868-603a-4433-876a-7d356e5cf377"));
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("e5095e9a-04f4-44c9-b443-1cf1b97d3384"));
private final TenantProfileId TENANT_PROFILE_ID = new TenantProfileId(UUID.fromString("ab78dd78-83d0-43fa-869f-d42ec9ed1744"));
private TbMsgTimeseriesNode node;
private TbMsgTimeseriesNodeConfiguration config;
private long tenantProfileDefaultStorageTtl;
@Mock
private TbContext ctxMock;
@Mock
private RuleEngineTelemetryService telemetryServiceMock;
@BeforeEach
public void setUp() throws TbNodeException {
node = new TbMsgTimeseriesNode();
config = new TbMsgTimeseriesNodeConfiguration().defaultConfiguration();
}
@Test
public void verifyDefaultConfig() {
assertThat(config.getDefaultTTL()).isEqualTo(0L);
assertThat(config.isSkipLatestPersistence()).isFalse();
assertThat(config.isUseServerTs()).isFalse();
}
@ParameterizedTest
@EnumSource(TbMsgType.class)
public void givenMsgTypeAndEmptyMsgData_whenOnMsg_thenVerifyFailureMsg(TbMsgType msgType) throws TbNodeException {
init();
TbMsg msg = TbMsg.newMsg(msgType, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_ARRAY);
node.onMsg(ctxMock, msg);
ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture());
if (TbMsgType.POST_TELEMETRY_REQUEST.equals(msgType)) {
assertThat(throwableCaptor.getValue()).isInstanceOf(IllegalArgumentException.class).hasMessage("Msg body is empty: " + msg.getData());
verifyNoMoreInteractions(ctxMock);
return;
}
assertThat(throwableCaptor.getValue()).isInstanceOf(IllegalArgumentException.class).hasMessage("Unsupported msg type: " + msgType);
verifyNoMoreInteractions(ctxMock);
}
@Test
public void givenTtlFromConfigIsZeroAndUseServiceTsIsTrue_whenOnMsg_thenSaveTimeseriesUsingTenantProfileDefaultTtl() throws TbNodeException {
config.setUseServerTs(true);
init();
String data = """
{
"temp": 45,
"humidity": 77
}
""";
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, data);
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock);
when(ctxMock.getTenantId()).thenReturn(TENANT_ID);
doAnswer(invocation -> {
TelemetryNodeCallback callback = invocation.getArgument(5);
callback.onSuccess(null);
return null;
}).when(telemetryServiceMock).saveAndNotify(any(), any(), any(), anyList(), anyLong(), any());
node.onMsg(ctxMock, msg);
List<TsKvEntry> expectedList = getTsKvEntriesListWithTs(data, System.currentTimeMillis());
ArgumentCaptor<List<TsKvEntry>> entryListCaptor = ArgumentCaptor.forClass(List.class);
verify(telemetryServiceMock).saveAndNotify(eq(TENANT_ID), isNull(), eq(DEVICE_ID), entryListCaptor.capture(),
eq(tenantProfileDefaultStorageTtl), any(TelemetryNodeCallback.class));
assertThat(entryListCaptor.getValue()).usingRecursiveFieldByFieldElementComparatorIgnoringFields("ts")
.containsExactlyElementsOf(expectedList);
verify(ctxMock).tellSuccess(msg);
verifyNoMoreInteractions(ctxMock, telemetryServiceMock);
}
@Test
public void givenSkipLatestPersistenceIsTrueAndTtlFromConfig_whenOnMsg_thenSaveTimeseriesUsingTtlFromConfig() throws TbNodeException {
long ttlFromConfig = 5L;
config.setDefaultTTL(ttlFromConfig);
config.setSkipLatestPersistence(true);
init();
String data = """
{
"temp": 45,
"humidity": 77
}
""";
long ts = System.currentTimeMillis();
var metadata = Map.of("ts", String.valueOf(ts));
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, new TbMsgMetaData(metadata), data);
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock);
when(ctxMock.getTenantId()).thenReturn(TENANT_ID);
doAnswer(invocation -> {
TelemetryNodeCallback callback = invocation.getArgument(5);
callback.onSuccess(null);
return null;
}).when(telemetryServiceMock).saveWithoutLatestAndNotify(any(), any(), any(), anyList(), anyLong(), any());
node.onMsg(ctxMock, msg);
List<TsKvEntry> expectedList = getTsKvEntriesListWithTs(data, ts);
ArgumentCaptor<List<TsKvEntry>> entryListCaptor = ArgumentCaptor.forClass(List.class);
verify(telemetryServiceMock).saveWithoutLatestAndNotify(
eq(TENANT_ID), isNull(), eq(DEVICE_ID), entryListCaptor.capture(), eq(ttlFromConfig), any(TelemetryNodeCallback.class));
assertThat(entryListCaptor.getValue()).containsExactlyElementsOf(expectedList);
verify(ctxMock).tellSuccess(msg);
verifyNoMoreInteractions(ctxMock, telemetryServiceMock);
}
@ParameterizedTest
@MethodSource
public void givenTtlFromConfigAndTtlFromMd_whenOnMsg_thenVerifyTtl(String ttlFromMd, long ttlFromConfig, long expectedTtl) throws TbNodeException {
config.setDefaultTTL(ttlFromConfig);
init();
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock);
when(ctxMock.getTenantId()).thenReturn(TENANT_ID);
String data = """
{
"temp": 45,
"humidity": 77
}
""";
var metadata = new TbMsgMetaData();
metadata.putValue("TTL", ttlFromMd);
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metadata, data);
node.onMsg(ctxMock, msg);
verify(telemetryServiceMock).saveAndNotify(eq(TENANT_ID), isNull(), eq(DEVICE_ID), anyList(), eq(expectedTtl), any(TelemetryNodeCallback.class));
}
private static Stream<Arguments> givenTtlFromConfigAndTtlFromMd_whenOnMsg_thenVerifyTtl() {
return Stream.of(
// when ttl is present in metadata and it is not zero then ttl = ttl from metadata
Arguments.of("1", 2L, 1L),
// when ttl is absent in metadata and present in config and it is not zero then ttl = ttl from config
Arguments.of("", 3L, 3L),
Arguments.of(null, 4L, 4L),
// when ttl is present in metadata or config but it is zero then ttl = default ttl from tenant profile
Arguments.of("0", 0L, TimeUnit.DAYS.toSeconds(5L))
);
}
private void init() throws TbNodeException {
var configuration = new TbNodeConfiguration(JacksonUtil.valueToTree(config));
var tenantProfile = getTenantProfile();
when(ctxMock.getTenantProfile()).thenReturn(tenantProfile);
tenantProfile.getProfileConfiguration().ifPresent(profileConfiguration ->
tenantProfileDefaultStorageTtl = TimeUnit.DAYS.toSeconds(profileConfiguration.getDefaultStorageTtlDays()));
node.init(ctxMock, configuration);
verify(ctxMock).addTenantProfileListener(any());
}
private TenantProfile getTenantProfile() {
var tenantProfile = new TenantProfile(TENANT_PROFILE_ID);
var tenantProfileData = new TenantProfileData();
var tenantProfileConfiguration = new DefaultTenantProfileConfiguration();
tenantProfileConfiguration.setDefaultStorageTtlDays(5);
tenantProfileData.setConfiguration(tenantProfileConfiguration);
tenantProfile.setProfileData(tenantProfileData);
return tenantProfile;
}
private static List<TsKvEntry> getTsKvEntriesListWithTs(String data, long ts) {
Map<Long, List<KvEntry>> tsKvMap = JsonConverter.convertToTelemetry(JsonParser.parseString(data), ts);
List<TsKvEntry> expectedList = new ArrayList<>();
for (Map.Entry<Long, List<KvEntry>> tsKvEntry : tsKvMap.entrySet()) {
for (KvEntry kvEntry : tsKvEntry.getValue()) {
expectedList.add(new BasicTsKvEntry(tsKvEntry.getKey(), kvEntry));
}
}
return expectedList;
}
}

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

@ -133,6 +133,12 @@ import {
DigitalSimpleGaugeBasicConfigComponent
} from '@home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component';
import { MobileAppQrCodeBasicConfigComponent } from '@home/components/widget/config/basic/cards/mobile-app-qr-code-basic-config.component';
import {
LabelCardBasicConfigComponent
} from '@home/components/widget/config/basic/cards/label-card-basic-config.component';
import {
LabelValueCardBasicConfigComponent
} from '@home/components/widget/config/basic/cards/label-value-card-basic-config.component';
@NgModule({
declarations: [
@ -177,7 +183,9 @@ import { MobileAppQrCodeBasicConfigComponent } from '@home/components/widget/con
PolarAreaChartBasicConfigComponent,
RadarChartBasicConfigComponent,
DigitalSimpleGaugeBasicConfigComponent,
MobileAppQrCodeBasicConfigComponent
MobileAppQrCodeBasicConfigComponent,
LabelCardBasicConfigComponent,
LabelValueCardBasicConfigComponent
],
imports: [
CommonModule,
@ -224,7 +232,9 @@ import { MobileAppQrCodeBasicConfigComponent } from '@home/components/widget/con
PolarAreaChartBasicConfigComponent,
RadarChartBasicConfigComponent,
DigitalSimpleGaugeBasicConfigComponent,
MobileAppQrCodeBasicConfigComponent
MobileAppQrCodeBasicConfigComponent,
LabelCardBasicConfigComponent,
LabelValueCardBasicConfigComponent
]
})
export class BasicWidgetConfigModule {
@ -265,5 +275,7 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type<IBasicWidgetCo
'tb-polar-area-chart-basic-config': PolarAreaChartBasicConfigComponent,
'tb-radar-chart-basic-config': RadarChartBasicConfigComponent,
'tb-digital-simple-gauge-basic-config': DigitalSimpleGaugeBasicConfigComponent,
'tb-mobile-app-qr-code-basic-config': MobileAppQrCodeBasicConfigComponent
'tb-mobile-app-qr-code-basic-config': MobileAppQrCodeBasicConfigComponent,
'tb-label-card-basic-config': LabelCardBasicConfigComponent,
'tb-label-value-card-basic-config': LabelValueCardBasicConfigComponent
};

88
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-card-basic-config.component.html

@ -0,0 +1,88 @@
<!--
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]="labelCardWidgetConfigForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.label-card.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" translate>widgets.label-card.label</div>
<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]="labelCardWidgetConfigForm.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.label-card.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]="labelCardWidgetConfigForm.get('iconColor').value"
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 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 class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-widget-actions-panel
formControlName="actions">
</tb-widget-actions-panel>
</ng-container>

130
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-card-basic-config.component.ts

@ -0,0 +1,130 @@
///
/// 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 { WidgetConfig, } from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { isUndefined } from '@core/utils';
import {
labelCardWidgetDefaultSettings,
LabelCardWidgetSettings
} from '@home/components/widget/lib/cards/label-card-widget.models';
@Component({
selector: 'tb-label-card-basic-config',
templateUrl: './label-card-basic-config.component.html',
styleUrls: ['../basic-config.scss']
})
export class LabelCardBasicConfigComponent extends BasicWidgetConfigComponent {
labelCardWidgetConfigForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.labelCardWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: LabelCardWidgetSettings = {...labelCardWidgetDefaultSettings, ...(configData.config.settings || {})};
this.labelCardWidgetConfigForm = this.fb.group({
autoScale: [settings.autoScale, []],
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, []],
background: [settings.background, []],
cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []],
padding: [settings.padding, []],
actions: [configData.config.actions || {}, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
this.widgetConfig.config.settings.autoScale = config.autoScale;
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.background = config.background;
this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.settings.padding = config.padding;
this.widgetConfig.config.actions = config.actions;
return this.widgetConfig;
}
protected validatorTriggers(): string[] {
return ['showIcon'];
}
protected updateValidators(emitEvent: boolean, trigger?: string) {
const showIcon: boolean = this.labelCardWidgetConfigForm.get('showIcon').value;
if (showIcon) {
this.labelCardWidgetConfigForm.get('iconSize').enable();
this.labelCardWidgetConfigForm.get('iconSizeUnit').enable();
this.labelCardWidgetConfigForm.get('icon').enable();
this.labelCardWidgetConfigForm.get('iconColor').enable();
} else {
this.labelCardWidgetConfigForm.get('iconSize').disable();
this.labelCardWidgetConfigForm.get('iconSizeUnit').disable();
this.labelCardWidgetConfigForm.get('icon').disable();
this.labelCardWidgetConfigForm.get('iconColor').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');
}
}

114
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-value-card-basic-config.component.html

@ -0,0 +1,114 @@
<!--
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]="labelValueCardWidgetConfigForm">
<tb-timewindow-config-panel *ngIf="displayTimewindowConfig"
[onlyHistoryTimewindow]="onlyHistoryTimewindow()"
formControlName="timewindowConfig">
</tb-timewindow-config-panel>
<tb-datasources
[configMode]="basicMode"
hideDatasourceLabel
hideDataKeyLabel
hideDataKeyColor
hideDataKeyUnits
hideDataKeyDecimals
formControlName="datasources">
</tb-datasources>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.label-card.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showLabel">
{{ 'widgets.label-card.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]="labelValueCardWidgetConfigForm.get('label').value">
</tb-font-settings>
<tb-color-settings formControlName="labelColor" settingsKey="{{'widgets.label-card.label' | translate }}">
</tb-color-settings>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.label-card.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]="labelValueCardWidgetConfigForm.get('iconColor').value?.color"
formControlName="icon">
</tb-material-icon-select>
<tb-color-settings formControlName="iconColor" settingsKey="{{'widgets.label-card.icon' | translate }}">
</tb-color-settings>
</div>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" translate>widgets.label-value-card.value</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-unit-input class="flex" formControlName="units"></tb-unit-input>
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix fxHide.lt-md translate>widget-config.decimals-suffix</div>
</mat-form-field>
<tb-font-settings formControlName="valueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-settings formControlName="valueColor" settingsKey="{{'widgets.label-value-card.value' | translate }}">
</tb-color-settings>
</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 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 class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-widget-actions-panel
formControlName="actions">
</tb-widget-actions-panel>
</ng-container>

191
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-value-card-basic-config.component.ts

@ -0,0 +1,191 @@
///
/// 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 {
DataKey,
datasourcesHasAggregation,
datasourcesHasOnlyComparisonAggregation,
WidgetConfig,
} from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { formatValue, isUndefined } from '@core/utils';
import {
labelValueCardWidgetDefaultSettings,
LabelValueCardWidgetSettings
} from '@home/components/widget/lib/cards/label-value-card-widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import {
getTimewindowConfig,
setTimewindowConfig
} from '@home/components/widget/config/timewindow-config-panel.component';
@Component({
selector: 'tb-label-value-card-basic-config',
templateUrl: './label-value-card-basic-config.component.html',
styleUrls: ['../basic-config.scss']
})
export class LabelValueCardBasicConfigComponent extends BasicWidgetConfigComponent {
public get displayTimewindowConfig(): boolean {
const datasources = this.labelValueCardWidgetConfigForm.get('datasources').value;
return datasourcesHasAggregation(datasources);
}
public onlyHistoryTimewindow(): boolean {
const datasources = this.labelValueCardWidgetConfigForm.get('datasources').value;
return datasourcesHasOnlyComparisonAggregation(datasources);
}
labelValueCardWidgetConfigForm: UntypedFormGroup;
valuePreviewFn = this._valuePreviewFn.bind(this);
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.labelValueCardWidgetConfigForm;
}
protected defaultDataKeys(configData: WidgetConfigComponentData): DataKey[] {
return [{ name: 'temperature', label: 'Temperature', type: DataKeyType.timeseries }];
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: LabelValueCardWidgetSettings = {...labelValueCardWidgetDefaultSettings, ...(configData.config.settings || {})};
this.labelValueCardWidgetConfigForm = this.fb.group({
timewindowConfig: [getTimewindowConfig(configData.config), []],
datasources: [configData.config.datasources, []],
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, []],
units: [configData.config.units, []],
decimals: [configData.config.decimals, []],
valueFont: [settings.valueFont, []],
valueColor: [settings.valueColor, []],
background: [settings.background, []],
cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []],
padding: [settings.padding, []],
actions: [configData.config.actions || {}, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig);
this.widgetConfig.config.datasources = config.datasources;
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
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.units = config.units;
this.widgetConfig.config.decimals = config.decimals;
this.widgetConfig.config.settings.valueFont = config.valueFont;
this.widgetConfig.config.settings.valueColor = config.valueColor;
this.widgetConfig.config.settings.background = config.background;
this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.settings.padding = config.padding;
this.widgetConfig.config.actions = config.actions;
return this.widgetConfig;
}
protected validatorTriggers(): string[] {
return ['showLabel', 'showIcon'];
}
protected updateValidators(emitEvent: boolean, trigger?: string) {
const showLabel: boolean = this.labelValueCardWidgetConfigForm.get('showLabel').value;
const showIcon: boolean = this.labelValueCardWidgetConfigForm.get('showIcon').value;
if (showLabel) {
this.labelValueCardWidgetConfigForm.get('label').enable();
this.labelValueCardWidgetConfigForm.get('labelFont').enable();
this.labelValueCardWidgetConfigForm.get('labelColor').enable();
} else {
this.labelValueCardWidgetConfigForm.get('label').disable();
this.labelValueCardWidgetConfigForm.get('labelFont').disable();
this.labelValueCardWidgetConfigForm.get('labelColor').disable();
}
if (showIcon) {
this.labelValueCardWidgetConfigForm.get('iconSize').enable();
this.labelValueCardWidgetConfigForm.get('iconSizeUnit').enable();
this.labelValueCardWidgetConfigForm.get('icon').enable();
this.labelValueCardWidgetConfigForm.get('iconColor').enable();
} else {
this.labelValueCardWidgetConfigForm.get('iconSize').disable();
this.labelValueCardWidgetConfigForm.get('iconSizeUnit').disable();
this.labelValueCardWidgetConfigForm.get('icon').disable();
this.labelValueCardWidgetConfigForm.get('iconColor').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');
}
private _valuePreviewFn(): string {
const units: string = this.labelValueCardWidgetConfigForm.get('units').value;
const decimals: number = this.labelValueCardWidgetConfigForm.get('decimals').value;
return formatValue(22, decimals, units, true);
}
}

30
ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html

@ -0,0 +1,30 @@
<!--
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 #labelCardPanel class="tb-label-card-panel" [style.padding]="padding" [style]="backgroundStyle$ | async"
[class.tb-label-card-pointer]="hasCardClickAction" (click)="cardClick($event)">
<div class="tb-label-card-overlay" [style]="overlayStyle"></div>
<div class="tb-label-card-title-panel">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
</div>
<div #labelCardContent class="tb-label-card-content">
<div #labelCardRow class="tb-label-card-row">
<tb-icon *ngIf="showIcon" [style]="iconStyle">{{ icon }}</tb-icon>
<div [class.tb-label-card-label]="!settings.autoScale" [style]="labelStyle" [innerHTML]="label | safe:'html'"></div>
</div>
</div>
</div>

70
ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.scss

@ -0,0 +1,70 @@
/**
* 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.
*/
.tb-label-card-panel {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 20px 24px;
&.tb-label-card-pointer {
cursor: pointer;
}
> div:not(.tb-label-card-overlay), > tb-icon {
z-index: 1;
}
.tb-label-card-overlay {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px;
right: 12px;
}
> div.tb-label-card-title-panel {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
z-index: 2;
}
div.tb-widget-title {
padding: 0;
}
.tb-label-card-content {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
.tb-label-card-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
}
.tb-label-card-label {
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
}
}
}

170
ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.ts

@ -0,0 +1,170 @@
///
/// 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,
Input,
OnDestroy,
OnInit,
Renderer2,
TemplateRef,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {
labelCardWidgetDefaultSettings,
LabelCardWidgetSettings
} from '@home/components/widget/lib/cards/label-card-widget.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { Observable } from 'rxjs';
import {
backgroundStyle,
ComponentStyle,
iconStyle,
overlayStyle,
resolveCssSize,
textStyle
} from '@shared/models/widget-settings.models';
import { ResizeObserver } from '@juggle/resize-observer';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
@Component({
selector: 'tb-label-card-widget',
templateUrl: './label-card-widget.component.html',
styleUrls: ['./label-card-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class LabelCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('labelCardPanel', {static: false})
labelCardPanel: ElementRef<HTMLElement>;
@ViewChild('labelCardContent', {static: false})
labelCardContent: ElementRef<HTMLElement>;
@ViewChild('labelCardRow', {static: false})
labelCardRow: ElementRef<HTMLElement>;
settings: LabelCardWidgetSettings;
@Input()
ctx: WidgetContext;
@Input()
widgetTitlePanel: TemplateRef<any>;
backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {};
padding: string;
label: string;
labelStyle: ComponentStyle = {};
showIcon = true;
icon = '';
iconStyle: ComponentStyle = {};
hasCardClickAction = false;
private panelResize$: ResizeObserver;
constructor(private imagePipe: ImagePipe,
private sanitizer: DomSanitizer,
private renderer: Renderer2,
private cd: ChangeDetectorRef) {
}
ngOnInit(): void {
this.ctx.$scope.labelCardWidget = this;
this.settings = {...labelCardWidgetDefaultSettings, ...this.ctx.settings};
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding;
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.label = this.settings.label;
this.labelStyle = textStyle(this.settings.labelFont);
this.labelStyle.color = this.settings.labelColor;
this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0;
}
public ngAfterViewInit() {
if (this.settings.autoScale) {
this.renderer.setStyle(this.labelCardContent.nativeElement, 'overflow', 'visible');
this.renderer.setStyle(this.labelCardContent.nativeElement, 'position', 'absolute');
this.panelResize$ = new ResizeObserver(() => {
this.onResize();
});
this.panelResize$.observe(this.labelCardPanel.nativeElement);
this.onResize();
}
}
ngOnDestroy() {
if (this.panelResize$) {
this.panelResize$.disconnect();
}
}
public onInit() {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges();
}
public cardClick($event: Event) {
this.ctx.actionsApi.cardClick($event);
}
private onResize() {
const paddingLeft = getComputedStyle(this.labelCardPanel.nativeElement).paddingLeft;
const paddingRight = getComputedStyle(this.labelCardPanel.nativeElement).paddingRight;
const paddingTop = getComputedStyle(this.labelCardPanel.nativeElement).paddingTop;
const paddingBottom = getComputedStyle(this.labelCardPanel.nativeElement).paddingBottom;
const pLeft = resolveCssSize(paddingLeft)[0];
const pRight = resolveCssSize(paddingRight)[0];
const pTop = resolveCssSize(paddingTop)[0];
const pBottom = resolveCssSize(paddingBottom)[0];
const panelWidth = this.labelCardPanel.nativeElement.getBoundingClientRect().width - (pLeft + pRight);
const panelHeight = this.labelCardPanel.nativeElement.getBoundingClientRect().height - (pTop + pBottom);
this.renderer.setStyle(this.labelCardContent.nativeElement, 'width', 'auto');
this.renderer.setStyle(this.labelCardContent.nativeElement, 'transform', `none`);
const contentWidth = this.labelCardRow.nativeElement.getBoundingClientRect().width;
const contentHeight = this.labelCardRow.nativeElement.getBoundingClientRect().height;
const panelAspect = panelWidth / panelHeight;
const contentAspect = contentWidth / contentHeight;
let scale: number;
if (contentAspect > panelAspect) {
scale = panelWidth / contentWidth;
} else {
scale = panelHeight / contentHeight;
}
const width = panelWidth / scale;
this.renderer.setStyle(this.labelCardContent.nativeElement, 'width', width + 'px');
this.renderer.setStyle(this.labelCardContent.nativeElement, 'transform', `scale(${scale})`);
}
}

60
ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.models.ts

@ -0,0 +1,60 @@
///
/// 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 { BackgroundSettings, BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models';
export interface LabelCardWidgetSettings {
autoScale: boolean;
label: string;
labelFont: Font;
labelColor: string;
showIcon: boolean;
icon: string;
iconSize: number;
iconSizeUnit: cssUnit;
iconColor: string;
background: BackgroundSettings;
padding: string;
}
export const labelCardWidgetDefaultSettings: LabelCardWidgetSettings = {
autoScale: true,
label: 'Thermostat',
labelFont: {
family: 'Roboto',
size: 20,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '24px'
},
labelColor: 'rgba(0, 0, 0, 0.87)',
showIcon: true,
icon: 'thermostat',
iconSize: 24,
iconSizeUnit: 'px',
iconColor: '#5469FF',
background: {
type: BackgroundType.color,
color: '#fff',
overlay: {
enabled: false,
color: 'rgba(255,255,255,0.72)',
blur: 3
}
},
padding: '12px'
};

34
ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.html

@ -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.
-->
<div #labelCardPanel class="tb-label-value-card-panel" [style.padding]="padding" [style]="backgroundStyle$ | async"
[class.tb-label-value-card-pointer]="hasCardClickAction" (click)="cardClick($event)">
<div class="tb-label-value-card-overlay" [style]="overlayStyle"></div>
<div class="tb-label-value-card-title-panel">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
</div>
<div #labelCardContent class="tb-label-value-card-content">
<div #labelCardRow class="tb-label-value-card-row">
<div *ngIf="showIcon || showLabel" class="tb-label-value-card-icon-label">
<tb-icon *ngIf="showIcon" [style]="iconStyle" [style.color]="iconColor.color">{{ icon }}</tb-icon>
<div *ngIf="showLabel" [class.tb-label-value-card-label]="!settings.autoScale" [style]="labelStyle" [style.color]="labelColor.color"
[innerHTML]="label$ | async | safe:'html'"></div>
</div>
<div class="tb-label-value-card-value" [style]="valueStyle" [style.color]="valueColor.color">{{ valueText }}</div>
</div>
</div>
</div>

80
ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.scss

@ -0,0 +1,80 @@
/**
* 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.
*/
.tb-label-value-card-panel {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 20px 24px;
&.tb-label-value-card-pointer {
cursor: pointer;
}
> div:not(.tb-label-value-card-overlay), > tb-icon {
z-index: 1;
}
.tb-label-value-card-overlay {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px;
right: 12px;
}
> div.tb-label-value-card-title-panel {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
z-index: 2;
}
div.tb-widget-title {
padding: 0;
}
.tb-label-value-card-content {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
.tb-label-value-card-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 16px;
}
.tb-label-value-card-icon-label {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
}
.tb-label-value-card-label {
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
}
.tb-label-value-card-value {
white-space: nowrap;
}
}
}

218
ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.ts

@ -0,0 +1,218 @@
///
/// 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,
Input,
OnDestroy,
OnInit,
Renderer2,
TemplateRef,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { WidgetContext } from '@home/models/widget-component.models';
import { Observable } from 'rxjs';
import {
backgroundStyle,
ColorProcessor,
ComponentStyle,
getDataKey,
getSingleTsValue,
iconStyle,
overlayStyle,
resolveCssSize,
textStyle
} from '@shared/models/widget-settings.models';
import { ResizeObserver } from '@juggle/resize-observer';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser';
import {
labelValueCardWidgetDefaultSettings,
LabelValueCardWidgetSettings
} from '@home/components/widget/lib/cards/label-value-card-widget.models';
import { formatValue, isDefinedAndNotNull } from '@core/utils';
@Component({
selector: 'tb-label-value-card-widget',
templateUrl: './label-value-card-widget.component.html',
styleUrls: ['./label-value-card-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class LabelValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('labelCardPanel', {static: false})
labelCardPanel: ElementRef<HTMLElement>;
@ViewChild('labelCardContent', {static: false})
labelCardContent: ElementRef<HTMLElement>;
@ViewChild('labelCardRow', {static: false})
labelCardRow: ElementRef<HTMLElement>;
settings: LabelValueCardWidgetSettings;
@Input()
ctx: WidgetContext;
@Input()
widgetTitlePanel: TemplateRef<any>;
backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {};
padding: string;
showLabel = true;
label$: Observable<string>;
labelStyle: ComponentStyle = {};
labelColor: ColorProcessor;
valueText = 'N/A';
valueStyle: ComponentStyle = {};
valueColor: ColorProcessor;
showIcon = true;
icon = '';
iconStyle: ComponentStyle = {};
iconColor: ColorProcessor;
hasCardClickAction = false;
private panelResize$: ResizeObserver;
private decimals = 0;
private units = '';
constructor(private imagePipe: ImagePipe,
private sanitizer: DomSanitizer,
private renderer: Renderer2,
private cd: ChangeDetectorRef) {
}
ngOnInit(): void {
this.ctx.$scope.labelValueCardWidget = this;
this.settings = {...labelValueCardWidgetDefaultSettings, ...this.ctx.settings};
this.decimals = this.ctx.decimals;
this.units = this.ctx.units;
const dataKey = getDataKey(this.ctx.datasources);
if (isDefinedAndNotNull(dataKey?.decimals)) {
this.decimals = dataKey.decimals;
}
if (dataKey?.units) {
this.units = dataKey.units;
}
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding;
this.showIcon = this.settings.showIcon;
this.icon = this.settings.icon;
this.iconStyle = iconStyle(this.settings.iconSize, this.settings.iconSizeUnit);
this.iconColor = ColorProcessor.fromSettings(this.settings.iconColor);
this.showLabel = this.settings.showLabel;
const label = this.settings.label;
this.label$ = this.ctx.registerLabelPattern(label, this.label$);
this.labelStyle = textStyle(this.settings.labelFont);
this.labelColor = ColorProcessor.fromSettings(this.settings.labelColor);
this.valueStyle = textStyle(this.settings.valueFont);
this.valueColor = ColorProcessor.fromSettings(this.settings.valueColor);
this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0;
}
public ngAfterViewInit() {
if (this.settings.autoScale) {
this.renderer.setStyle(this.labelCardContent.nativeElement, 'overflow', 'visible');
this.renderer.setStyle(this.labelCardContent.nativeElement, 'position', 'absolute');
this.panelResize$ = new ResizeObserver(() => {
this.onResize();
});
this.panelResize$.observe(this.labelCardPanel.nativeElement);
this.onResize();
}
}
ngOnDestroy() {
if (this.panelResize$) {
this.panelResize$.disconnect();
}
}
public onInit() {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
this.cd.detectChanges();
}
public onDataUpdated() {
const tsValue = getSingleTsValue(this.ctx.data);
let value;
if (tsValue && isDefinedAndNotNull(tsValue[1]) && tsValue[0] !== 0) {
value = tsValue[1];
this.valueText = formatValue(value, this.decimals, this.units, false);
} else {
this.valueText = 'N/A';
}
this.iconColor.update(value);
this.labelColor.update(value);
this.valueColor.update(value);
this.cd.detectChanges();
if (this.settings.autoScale) {
setTimeout(() => {
this.onResize();
}, 0);
}
}
public cardClick($event: Event) {
this.ctx.actionsApi.cardClick($event);
}
private onResize() {
const paddingLeft = getComputedStyle(this.labelCardPanel.nativeElement).paddingLeft;
const paddingRight = getComputedStyle(this.labelCardPanel.nativeElement).paddingRight;
const paddingTop = getComputedStyle(this.labelCardPanel.nativeElement).paddingTop;
const paddingBottom = getComputedStyle(this.labelCardPanel.nativeElement).paddingBottom;
const pLeft = resolveCssSize(paddingLeft)[0];
const pRight = resolveCssSize(paddingRight)[0];
const pTop = resolveCssSize(paddingTop)[0];
const pBottom = resolveCssSize(paddingBottom)[0];
const panelWidth = this.labelCardPanel.nativeElement.getBoundingClientRect().width - (pLeft + pRight);
const panelHeight = this.labelCardPanel.nativeElement.getBoundingClientRect().height - (pTop + pBottom);
this.renderer.setStyle(this.labelCardContent.nativeElement, 'width', 'auto');
this.renderer.setStyle(this.labelCardContent.nativeElement, 'transform', `none`);
const contentWidth = this.labelCardRow.nativeElement.getBoundingClientRect().width;
const contentHeight = this.labelCardRow.nativeElement.getBoundingClientRect().height;
const panelAspect = panelWidth / panelHeight;
const contentAspect = contentWidth / contentHeight;
let scale: number;
if (contentAspect > panelAspect) {
scale = panelWidth / contentWidth;
} else {
scale = panelHeight / contentHeight;
}
const width = panelWidth / scale;
this.renderer.setStyle(this.labelCardContent.nativeElement, 'width', width + 'px');
this.renderer.setStyle(this.labelCardContent.nativeElement, 'transform', `scale(${scale})`);
}
}

80
ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.models.ts

@ -0,0 +1,80 @@
///
/// 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 {
BackgroundSettings,
BackgroundType,
ColorSettings,
constantColor,
cssUnit,
Font
} from '@shared/models/widget-settings.models';
export interface LabelValueCardWidgetSettings {
autoScale: boolean;
showLabel: boolean;
label: string;
labelFont: Font;
labelColor: ColorSettings;
showIcon: boolean;
icon: string;
iconSize: number;
iconSizeUnit: cssUnit;
iconColor: ColorSettings;
valueFont: Font;
valueColor: ColorSettings;
background: BackgroundSettings;
padding: string;
}
export const labelValueCardWidgetDefaultSettings: LabelValueCardWidgetSettings = {
autoScale: true,
showLabel: true,
label: 'Temperature',
labelFont: {
family: 'Roboto',
size: 20,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '24px'
},
labelColor: constantColor('rgba(0, 0, 0, 0.87)'),
showIcon: true,
icon: 'thermostat',
iconSize: 24,
iconSizeUnit: 'px',
iconColor: constantColor('#5469FF'),
valueFont: {
family: 'Roboto',
size: 20,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '24px'
},
valueColor: constantColor('rgba(0, 0, 0, 0.87)'),
background: {
type: BackgroundType.color,
color: '#fff',
overlay: {
enabled: false,
color: 'rgba(255,255,255,0.72)',
blur: 3
}
},
padding: '12px'
};

73
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-card-widget-settings.component.html

@ -0,0 +1,73 @@
<!--
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]="labelCardWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.label-card.label-card-style</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.label-card.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" translate>widgets.label-card.label</div>
<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]="labelCardWidgetSettingsForm.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.label-card.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]="labelCardWidgetSettingsForm.get('iconColor').value"
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 class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
</ng-container>

83
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-card-widget-settings.component.ts

@ -0,0 +1,83 @@
///
/// 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 { 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 { labelCardWidgetDefaultSettings } from '@home/components/widget/lib/cards/label-card-widget.models';
@Component({
selector: 'tb-label-card-widget-settings',
templateUrl: './label-card-widget-settings.component.html',
styleUrls: []
})
export class LabelCardWidgetSettingsComponent extends WidgetSettingsComponent {
labelCardWidgetSettingsForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.labelCardWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return {...labelCardWidgetDefaultSettings};
}
protected onSettingsSet(settings: WidgetSettings) {
this.labelCardWidgetSettingsForm = this.fb.group({
autoScale: [settings.autoScale, []],
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, []],
background: [settings.background, []],
padding: [settings.padding, []]
});
}
protected validatorTriggers(): string[] {
return ['showIcon'];
}
protected updateValidators(emitEvent: boolean) {
const showIcon: boolean = this.labelCardWidgetSettingsForm.get('showIcon').value;
if (showIcon) {
this.labelCardWidgetSettingsForm.get('iconSize').enable();
this.labelCardWidgetSettingsForm.get('iconSizeUnit').enable();
this.labelCardWidgetSettingsForm.get('icon').enable();
this.labelCardWidgetSettingsForm.get('iconColor').enable();
} else {
this.labelCardWidgetSettingsForm.get('iconSize').disable();
this.labelCardWidgetSettingsForm.get('iconSizeUnit').disable();
this.labelCardWidgetSettingsForm.get('icon').disable();
this.labelCardWidgetSettingsForm.get('iconColor').disable();
}
}
}

81
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-value-card-widget-settings.component.html

@ -0,0 +1,81 @@
<!--
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]="labelValueCardWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.label-value-card.label-value-card-style</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.label-card.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="showLabel">
{{ 'widgets.label-card.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]="labelValueCardWidgetSettingsForm.get('label').value">
</tb-font-settings>
<tb-color-settings formControlName="labelColor" settingsKey="{{'widgets.label-card.label' | translate }}">
</tb-color-settings>
</div>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.label-card.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]="labelValueCardWidgetSettingsForm.get('iconColor').value?.color"
formControlName="icon">
</tb-material-icon-select>
<tb-color-settings formControlName="iconColor" settingsKey="{{'widgets.label-card.icon' | translate }}">
</tb-color-settings>
</div>
</div>
<div class="tb-form-row space-between">
<div translate>widgets.label-value-card.value</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="valueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-settings formControlName="valueColor" settingsKey="{{'widgets.label-value-card.value' | translate }}">
</tb-color-settings>
</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 class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
</ng-container>

108
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-value-card-widget-settings.component.ts

@ -0,0 +1,108 @@
///
/// 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 { 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 { labelValueCardWidgetDefaultSettings } from '@home/components/widget/lib/cards/label-value-card-widget.models';
import { formatValue } from '@core/utils';
@Component({
selector: 'tb-label-value-card-widget-settings',
templateUrl: './label-value-card-widget-settings.component.html',
styleUrls: []
})
export class LabelValueCardWidgetSettingsComponent extends WidgetSettingsComponent {
labelValueCardWidgetSettingsForm: UntypedFormGroup;
valuePreviewFn = this._valuePreviewFn.bind(this);
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.labelValueCardWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return {...labelValueCardWidgetDefaultSettings};
}
protected onSettingsSet(settings: WidgetSettings) {
this.labelValueCardWidgetSettingsForm = this.fb.group({
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, []],
valueFont: [settings.valueFont, []],
valueColor: [settings.valueColor, []],
background: [settings.background, []],
padding: [settings.padding, []]
});
}
protected validatorTriggers(): string[] {
return ['showLabel', 'showIcon'];
}
protected updateValidators(emitEvent: boolean) {
const showLabel: boolean = this.labelValueCardWidgetSettingsForm.get('showLabel').value;
const showIcon: boolean = this.labelValueCardWidgetSettingsForm.get('showIcon').value;
if (showLabel) {
this.labelValueCardWidgetSettingsForm.get('label').enable();
this.labelValueCardWidgetSettingsForm.get('labelFont').enable();
this.labelValueCardWidgetSettingsForm.get('labelColor').enable();
} else {
this.labelValueCardWidgetSettingsForm.get('label').disable();
this.labelValueCardWidgetSettingsForm.get('labelFont').disable();
this.labelValueCardWidgetSettingsForm.get('labelColor').disable();
}
if (showIcon) {
this.labelValueCardWidgetSettingsForm.get('iconSize').enable();
this.labelValueCardWidgetSettingsForm.get('iconSizeUnit').enable();
this.labelValueCardWidgetSettingsForm.get('icon').enable();
this.labelValueCardWidgetSettingsForm.get('iconColor').enable();
} else {
this.labelValueCardWidgetSettingsForm.get('iconSize').disable();
this.labelValueCardWidgetSettingsForm.get('iconSizeUnit').disable();
this.labelValueCardWidgetSettingsForm.get('icon').disable();
this.labelValueCardWidgetSettingsForm.get('iconColor').disable();
}
}
private _valuePreviewFn(): string {
const units: string = this.widgetConfig.config.units;
const decimals: number = this.widgetConfig.config.decimals;
return formatValue(22, decimals, units, true);
}
}

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

@ -356,6 +356,12 @@ import {
import {
MobileAppQrCodeWidgetSettingsComponent
} from '@home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component';
import {
LabelCardWidgetSettingsComponent
} from '@home/components/widget/lib/settings/cards/label-card-widget-settings.component';
import {
LabelValueCardWidgetSettingsComponent
} from '@home/components/widget/lib/settings/cards/label-value-card-widget-settings.component';
@NgModule({
declarations: [
@ -482,7 +488,9 @@ import {
PieChartWidgetSettingsComponent,
BarChartWidgetSettingsComponent,
PolarAreaChartWidgetSettingsComponent,
RadarChartWidgetSettingsComponent
RadarChartWidgetSettingsComponent,
LabelCardWidgetSettingsComponent,
LabelValueCardWidgetSettingsComponent
],
imports: [
CommonModule,
@ -614,7 +622,9 @@ import {
PieChartWidgetSettingsComponent,
BarChartWidgetSettingsComponent,
PolarAreaChartWidgetSettingsComponent,
RadarChartWidgetSettingsComponent
RadarChartWidgetSettingsComponent,
LabelCardWidgetSettingsComponent,
LabelValueCardWidgetSettingsComponent
]
})
export class WidgetSettingsModule {
@ -713,5 +723,7 @@ export const widgetSettingsComponentsMap: {[key: string]: Type<IWidgetSettingsCo
'tb-pie-chart-widget-settings': PieChartWidgetSettingsComponent,
'tb-bar-chart-widget-settings': BarChartWidgetSettingsComponent,
'tb-polar-area-chart-widget-settings': PolarAreaChartWidgetSettingsComponent,
'tb-radar-chart-widget-settings': RadarChartWidgetSettingsComponent
'tb-radar-chart-widget-settings': RadarChartWidgetSettingsComponent,
'tb-label-card-widget-settings': LabelCardWidgetSettingsComponent,
'tb-label-value-card-widget-settings': LabelValueCardWidgetSettingsComponent
};

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

@ -100,7 +100,11 @@ import { BarChartWidgetComponent } from '@home/components/widget/lib/chart/bar-c
import { PolarAreaWidgetComponent } from '@home/components/widget/lib/chart/polar-area-widget.component';
import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/radar-chart-widget.component';
import { MobileAppQrcodeWidgetComponent } from '@home/components/widget/lib/mobile-app-qrcode-widget.component';
import { RestConnectorSecurityComponent } from '@home/components/widget/lib/gateway/connectors-configuration/rest-connector-secuirity/rest-connector-security.component';
import { LabelCardWidgetComponent } from '@home/components/widget/lib/cards/label-card-widget.component';
import { LabelValueCardWidgetComponent } from '@home/components/widget/lib/cards/label-value-card-widget.component';
import {
RestConnectorSecurityComponent
} from '@home/components/widget/lib/gateway/connectors-configuration/rest-connector-secuirity/rest-connector-security.component';
@NgModule({
declarations:
@ -167,7 +171,9 @@ import { RestConnectorSecurityComponent } from '@home/components/widget/lib/gate
PieChartWidgetComponent,
BarChartWidgetComponent,
PolarAreaWidgetComponent,
RadarChartWidgetComponent
RadarChartWidgetComponent,
LabelCardWidgetComponent,
LabelValueCardWidgetComponent
],
imports: [
CommonModule,
@ -238,7 +244,9 @@ import { RestConnectorSecurityComponent } from '@home/components/widget/lib/gate
PieChartWidgetComponent,
BarChartWidgetComponent,
PolarAreaWidgetComponent,
RadarChartWidgetComponent
RadarChartWidgetComponent,
LabelCardWidgetComponent,
LabelValueCardWidgetComponent
],
providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule }

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

@ -6895,6 +6895,16 @@
"value-card-style": "Value card style",
"auto-scale": "Auto scale"
},
"label-card": {
"auto-scale": "Auto scale",
"label": "Label",
"icon": "Icon",
"label-card-style": "Label card style"
},
"label-value-card": {
"value": "Value",
"label-value-card-style": "Label & value card style"
},
"liquid-level-card": {
"layout-simple": "Simple",
"layout-percentage": "Percentage",

Loading…
Cancel
Save