diff --git a/.github/release.yml b/.github/release.yml index 1d760fe7bf..5e0ddc4300 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2023 The Thingsboard Authors +# 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. diff --git a/.github/workflows/check-configuration-files.yml b/.github/workflows/check-configuration-files.yml index 0b292ded79..94bfc85ce3 100644 --- a/.github/workflows/check-configuration-files.yml +++ b/.github/workflows/check-configuration-files.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2023 The Thingsboard Authors +# 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. diff --git a/application/pom.xml b/application/pom.xml index ee01bfbaf3..aaa8a1f447 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -1,6 +1,6 @@ + + 4.0.0 + + org.thingsboard + 3.6.3-SNAPSHOT + common + + org.thingsboard.common + proto + jar + + Thingsboard Server Common Protobuf and gRPC structures + https://thingsboard.io + + + UTF-8 + ${basedir}/../.. + + + + + org.thingsboard.common + data + + + org.thingsboard.common + message + + + com.google.protobuf + protobuf-java + + + com.google.protobuf + protobuf-java-util + + + org.springframework.boot + spring-boot-starter-web + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.awaitility + awaitility + test + + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + + + + + thingsboard-repo-deploy + ThingsBoard Repo Deployment + https://repo.thingsboard.io/artifactory/libs-release-public + + + + \ No newline at end of file diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/AdaptorException.java similarity index 90% rename from common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java rename to common/proto/src/main/java/org/thingsboard/server/common/adaptor/AdaptorException.java index 8b2908a4b5..226ea644a6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/AdaptorException.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.adaptor; +package org.thingsboard.server.common.adaptor; public class AdaptorException extends Exception { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java similarity index 99% rename from common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java rename to common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index 51e74d5097..42da1d740f 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.adaptor; +package org.thingsboard.server.common.adaptor; import com.google.gson.Gson; import com.google.gson.JsonArray; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverterConfig.java similarity index 92% rename from common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java rename to common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverterConfig.java index 0a9df150e5..335bf2265b 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverterConfig.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.adaptor; +package org.thingsboard.server.common.adaptor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/ProtoConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java similarity index 99% rename from common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/ProtoConverter.java rename to common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java index 54358709f0..b90b5f054f 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/ProtoConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.adaptor; +package org.thingsboard.server.common.adaptor; import com.google.gson.Gson; import com.google.gson.JsonElement; diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java new file mode 100644 index 0000000000..9daa9de9da --- /dev/null +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -0,0 +1,499 @@ +/** + * 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.server.common.util; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKey; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg; +import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; +import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; +import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponseActorMsg; +import org.thingsboard.server.common.msg.rpc.RemoveRpcActorMsg; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequestActorMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceCredentialsUpdateNotificationMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceDeleteMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceEdgeUpdateMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceNameOrTypeUpdateMsg; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +public class ProtoUtils { + + private static final EntityType[] entityTypeByProtoNumber; + + static { + int arraySize = Arrays.stream(EntityType.values()).mapToInt(EntityType::getProtoNumber).max().orElse(0); + entityTypeByProtoNumber = new EntityType[arraySize + 1]; + Arrays.stream(EntityType.values()).forEach(entityType -> entityTypeByProtoNumber[entityType.getProtoNumber()] = entityType); + } + + public static TransportProtos.ComponentLifecycleMsgProto toProto(ComponentLifecycleMsg msg) { + return TransportProtos.ComponentLifecycleMsgProto.newBuilder() + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setEntityType(toProto(msg.getEntityId().getEntityType())) + .setEntityIdMSB(msg.getEntityId().getId().getMostSignificantBits()) + .setEntityIdLSB(msg.getEntityId().getId().getLeastSignificantBits()) + .setEvent(TransportProtos.ComponentLifecycleEvent.forNumber(msg.getEvent().ordinal())) + .build(); + } + + public static TransportProtos.EntityTypeProto toProto(EntityType entityType) { + return TransportProtos.EntityTypeProto.forNumber(entityType.getProtoNumber()); + } + + public static ComponentLifecycleMsg fromProto(TransportProtos.ComponentLifecycleMsgProto proto) { + return new ComponentLifecycleMsg( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + EntityIdFactory.getByTypeAndUuid(fromProto(proto.getEntityType()), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())), + ComponentLifecycleEvent.values()[proto.getEventValue()] + ); + } + + public static EntityType fromProto(TransportProtos.EntityTypeProto entityType) { + return entityTypeByProtoNumber[entityType.getNumber()]; + } + + + public static TransportProtos.ToEdgeSyncRequestMsgProto toProto(ToEdgeSyncRequest request) { + return TransportProtos.ToEdgeSyncRequestMsgProto.newBuilder() + .setTenantIdMSB(request.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(request.getTenantId().getId().getLeastSignificantBits()) + .setRequestIdMSB(request.getId().getMostSignificantBits()) + .setRequestIdLSB(request.getId().getLeastSignificantBits()) + .setEdgeIdMSB(request.getEdgeId().getId().getMostSignificantBits()) + .setEdgeIdLSB(request.getEdgeId().getId().getLeastSignificantBits()) + .build(); + } + + public static ToEdgeSyncRequest fromProto(TransportProtos.ToEdgeSyncRequestMsgProto proto) { + return new ToEdgeSyncRequest( + new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()), + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new EdgeId(new UUID(proto.getEdgeIdMSB(), proto.getEdgeIdLSB())) + ); + } + + public static TransportProtos.FromEdgeSyncResponseMsgProto toProto(FromEdgeSyncResponse response) { + return TransportProtos.FromEdgeSyncResponseMsgProto.newBuilder() + .setTenantIdMSB(response.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(response.getTenantId().getId().getLeastSignificantBits()) + .setResponseIdMSB(response.getId().getMostSignificantBits()) + .setResponseIdLSB(response.getId().getLeastSignificantBits()) + .setEdgeIdMSB(response.getEdgeId().getId().getMostSignificantBits()) + .setEdgeIdLSB(response.getEdgeId().getId().getLeastSignificantBits()) + .setSuccess(response.isSuccess()) + .build(); + } + + public static FromEdgeSyncResponse fromProto(TransportProtos.FromEdgeSyncResponseMsgProto proto) { + return new FromEdgeSyncResponse( + new UUID(proto.getResponseIdMSB(), proto.getResponseIdLSB()), + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new EdgeId(new UUID(proto.getEdgeIdMSB(), proto.getEdgeIdLSB())), + proto.getSuccess() + ); + } + + public static TransportProtos.EdgeEventUpdateMsgProto toProto(EdgeEventUpdateMsg msg) { + return TransportProtos.EdgeEventUpdateMsgProto.newBuilder() + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setEdgeIdMSB(msg.getEdgeId().getId().getMostSignificantBits()) + .setEdgeIdLSB(msg.getEdgeId().getId().getLeastSignificantBits()) + .build(); + } + + public static EdgeEventUpdateMsg fromProto(TransportProtos.EdgeEventUpdateMsgProto proto) { + return new EdgeEventUpdateMsg( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new EdgeId(new UUID(proto.getEdgeIdMSB(), proto.getEdgeIdLSB())) + ); + } + + private static TransportProtos.DeviceEdgeUpdateMsgProto toProto(DeviceEdgeUpdateMsg msg) { + TransportProtos.DeviceEdgeUpdateMsgProto.Builder builder = TransportProtos.DeviceEdgeUpdateMsgProto.newBuilder() + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceId().getId().getLeastSignificantBits()); + + if (msg.getEdgeId() != null) { + builder.setEdgeIdMSB(msg.getEdgeId().getId().getMostSignificantBits()) + .setEdgeIdLSB(msg.getEdgeId().getId().getLeastSignificantBits()); + } + + return builder.build(); + } + + private static DeviceEdgeUpdateMsg fromProto(TransportProtos.DeviceEdgeUpdateMsgProto proto) { + EdgeId edgeId = null; + if (proto.hasEdgeIdMSB() && proto.hasEdgeIdLSB()) { + edgeId = new EdgeId(new UUID(proto.getEdgeIdMSB(), proto.getEdgeIdLSB())); + } + return new DeviceEdgeUpdateMsg( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB())), + edgeId); + } + + private static TransportProtos.DeviceNameOrTypeUpdateMsgProto toProto(DeviceNameOrTypeUpdateMsg msg) { + return TransportProtos.DeviceNameOrTypeUpdateMsgProto.newBuilder() + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceId().getId().getLeastSignificantBits()) + .setDeviceName(msg.getDeviceName()) + .setDeviceType(msg.getDeviceType()) + .build(); + } + + private static DeviceNameOrTypeUpdateMsg fromProto(TransportProtos.DeviceNameOrTypeUpdateMsgProto proto) { + return new DeviceNameOrTypeUpdateMsg( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB())), + proto.getDeviceName(), + proto.getDeviceType() + ); + } + + private static TransportProtos.DeviceAttributesEventMsgProto toProto(DeviceAttributesEventNotificationMsg msg) { + TransportProtos.DeviceAttributesEventMsgProto.Builder builder = TransportProtos.DeviceAttributesEventMsgProto.newBuilder(); + builder.setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceId().getId().getLeastSignificantBits()) + .setDeleted(msg.isDeleted()); + + if (msg.getScope() != null) { + builder.setScope(TransportProtos.AttributeScopeProto.valueOf(msg.getScope())); + } + + if (msg.getDeletedKeys() != null) { + for (AttributeKey key : msg.getDeletedKeys()) { + builder.addDeletedKeys(TransportProtos.AttributeKey.newBuilder() + .setScope(TransportProtos.AttributeScopeProto.valueOf(key.getScope())) + .setAttributeKey(key.getAttributeKey()) + .build()); + } + } + + if (msg.getValues() != null) { + for (AttributeKvEntry attributeKvEntry : msg.getValues()) { + TransportProtos.AttributeValueProto.Builder attributeValueBuilder = TransportProtos.AttributeValueProto.newBuilder() + .setLastUpdateTs(attributeKvEntry.getLastUpdateTs()) + .setKey(attributeKvEntry.getKey()); + switch (attributeKvEntry.getDataType()) { + case BOOLEAN: + attributeKvEntry.getBooleanValue().ifPresent(attributeValueBuilder::setBoolV); + attributeValueBuilder.setHasV(attributeKvEntry.getBooleanValue().isPresent()); + attributeValueBuilder.setType(TransportProtos.KeyValueType.BOOLEAN_V); + break; + case STRING: + attributeKvEntry.getStrValue().ifPresent(attributeValueBuilder::setStringV); + attributeValueBuilder.setHasV(attributeKvEntry.getStrValue().isPresent()); + attributeValueBuilder.setType(TransportProtos.KeyValueType.STRING_V); + break; + case DOUBLE: + attributeKvEntry.getDoubleValue().ifPresent(attributeValueBuilder::setDoubleV); + attributeValueBuilder.setHasV(attributeKvEntry.getDoubleValue().isPresent()); + attributeValueBuilder.setType(TransportProtos.KeyValueType.DOUBLE_V); + break; + case LONG: + attributeKvEntry.getLongValue().ifPresent(attributeValueBuilder::setLongV); + attributeValueBuilder.setHasV(attributeKvEntry.getLongValue().isPresent()); + attributeValueBuilder.setType(TransportProtos.KeyValueType.LONG_V); + break; + case JSON: + attributeKvEntry.getJsonValue().ifPresent(attributeValueBuilder::setJsonV); + attributeValueBuilder.setHasV(attributeKvEntry.getJsonValue().isPresent()); + attributeValueBuilder.setType(TransportProtos.KeyValueType.JSON_V); + break; + } + builder.addValues(attributeValueBuilder.build()); + } + } + return builder.build(); + } + + private static ToDeviceActorNotificationMsg fromProto(TransportProtos.DeviceAttributesEventMsgProto proto) { + return new DeviceAttributesEventNotificationMsg( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB())), + getAttributeKeySetFromProto(proto.getDeletedKeysList()), + proto.hasScope() ? proto.getScope().name() : null, + getAttributesKvEntryFromProto(proto.getValuesList()), + proto.getDeleted() + ); + } + + private static TransportProtos.DeviceCredentialsUpdateMsgProto toProto(DeviceCredentialsUpdateNotificationMsg msg) { + TransportProtos.DeviceCredentialsProto.Builder protoBuilder = TransportProtos.DeviceCredentialsProto.newBuilder() + .setDeviceIdMSB(msg.getDeviceCredentials().getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceCredentials().getDeviceId().getId().getLeastSignificantBits()) + .setCredentialsId(msg.getDeviceCredentials().getCredentialsId()) + .setCredentialsType(TransportProtos.CredentialsType.valueOf(msg.getDeviceCredentials().getCredentialsType().name())); + + if (msg.getDeviceCredentials().getCredentialsValue() != null) { + protoBuilder.setCredentialsValue(msg.getDeviceCredentials().getCredentialsValue()); + } + + return TransportProtos.DeviceCredentialsUpdateMsgProto.newBuilder() + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceId().getId().getLeastSignificantBits()) + .setDeviceCredentials(protoBuilder.build()) + .build(); + } + + private static ToDeviceActorNotificationMsg fromProto(TransportProtos.DeviceCredentialsUpdateMsgProto proto) { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(new DeviceId(new UUID(proto.getDeviceCredentials().getDeviceIdMSB(), proto.getDeviceCredentials().getDeviceIdLSB()))); + deviceCredentials.setCredentialsId(proto.getDeviceCredentials().getCredentialsId()); + deviceCredentials.setCredentialsValue(proto.getDeviceCredentials().hasCredentialsValue() ? proto.getDeviceCredentials().getCredentialsValue() : null); + deviceCredentials.setCredentialsType(DeviceCredentialsType.valueOf(proto.getDeviceCredentials().getCredentialsType().name())); + return new DeviceCredentialsUpdateNotificationMsg( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB())), + deviceCredentials + ); + } + + private static TransportProtos.ToDeviceRpcRequestActorMsgProto toProto(ToDeviceRpcRequestActorMsg msg) { + TransportProtos.ToDeviceRpcRequestMsg proto = TransportProtos.ToDeviceRpcRequestMsg.newBuilder() + .setMethodName(msg.getMsg().getBody().getMethod()) + .setParams(msg.getMsg().getBody().getParams()) + .setExpirationTime(msg.getMsg().getExpirationTime()) + .setRequestIdMSB(msg.getMsg().getId().getMostSignificantBits()) + .setRequestIdLSB(msg.getMsg().getId().getLeastSignificantBits()) + .setOneway(msg.getMsg().isOneway()) + .build(); + + return TransportProtos.ToDeviceRpcRequestActorMsgProto.newBuilder() + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceId().getId().getLeastSignificantBits()) + .setServiceId(msg.getServiceId()) + .setToDeviceRpcRequestMsg(proto) + .build(); + } + + private static ToDeviceActorNotificationMsg fromProto(TransportProtos.ToDeviceRpcRequestActorMsgProto proto) { + TransportProtos.ToDeviceRpcRequestMsg toDeviceRpcRequestMsg = proto.getToDeviceRpcRequestMsg(); + ToDeviceRpcRequest toDeviceRpcRequest = new ToDeviceRpcRequest( + new UUID(toDeviceRpcRequestMsg.getRequestIdMSB(), toDeviceRpcRequestMsg.getRequestIdLSB()), + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB())), + toDeviceRpcRequestMsg.getOneway(), + toDeviceRpcRequestMsg.getExpirationTime(), + new ToDeviceRpcRequestBody(toDeviceRpcRequestMsg.getMethodName(), toDeviceRpcRequestMsg.getParams()), + toDeviceRpcRequestMsg.getPersisted(), 0, ""); + return new ToDeviceRpcRequestActorMsg(proto.getServiceId(), toDeviceRpcRequest); + } + + private static TransportProtos.FromDeviceRpcResponseActorMsgProto toProto(FromDeviceRpcResponseActorMsg msg) { + TransportProtos.FromDeviceRPCResponseProto.Builder builder = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(msg.getMsg().getId().getMostSignificantBits()) + .setRequestIdLSB(msg.getMsg().getId().getLeastSignificantBits()) + .setError(msg.getMsg().getError().isPresent() ? msg.getMsg().getError().get().ordinal() : -1); + if (msg.getMsg().getResponse().isPresent()) { + builder.setResponse(msg.getMsg().getResponse().get()); + } + + return TransportProtos.FromDeviceRpcResponseActorMsgProto.newBuilder() + .setRequestId(msg.getRequestId()) + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceId().getId().getLeastSignificantBits()) + .setRpcResponse(builder.build()) + .build(); + } + + private static ToDeviceActorNotificationMsg fromProto(TransportProtos.FromDeviceRpcResponseActorMsgProto proto) { + FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse( + new UUID(proto.getRpcResponse().getRequestIdMSB(), proto.getRpcResponse().getRequestIdLSB()), + proto.getRpcResponse().getResponse(), + proto.getRpcResponse().getError() >= 0 ? RpcError.values()[proto.getRpcResponse().getError()] : null); + return new FromDeviceRpcResponseActorMsg( + proto.getRequestId(), + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB())), + fromDeviceRpcResponse + ); + } + + private static TransportProtos.RemoveRpcActorMsgProto toProto(RemoveRpcActorMsg msg) { + return TransportProtos.RemoveRpcActorMsgProto.newBuilder() + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceId().getId().getLeastSignificantBits()) + .setRequestIdMSB(msg.getRequestId().getMostSignificantBits()) + .setRequestIdLSB(msg.getRequestId().getLeastSignificantBits()) + .build(); + } + + private static ToDeviceActorNotificationMsg fromProto(TransportProtos.RemoveRpcActorMsgProto proto) { + return new RemoveRpcActorMsg( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB())), + new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) + ); + } + + private static TransportProtos.DeviceDeleteMsgProto toProto(DeviceDeleteMsg msg) { + return TransportProtos.DeviceDeleteMsgProto.newBuilder() + .setTenantIdMSB(msg.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(msg.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(msg.getDeviceId().getId().getLeastSignificantBits()) + .build(); + } + + private static DeviceDeleteMsg fromProto(TransportProtos.DeviceDeleteMsgProto proto) { + return new DeviceDeleteMsg( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB()))); + } + + public static TransportProtos.ToDeviceActorNotificationMsgProto toProto(ToDeviceActorNotificationMsg msg) { + if (msg instanceof DeviceEdgeUpdateMsg) { + DeviceEdgeUpdateMsg updateMsg = (DeviceEdgeUpdateMsg) msg; + TransportProtos.DeviceEdgeUpdateMsgProto proto = toProto(updateMsg); + return TransportProtos.ToDeviceActorNotificationMsgProto.newBuilder().setDeviceEdgeUpdateMsg(proto).build(); + } else if (msg instanceof DeviceNameOrTypeUpdateMsg) { + DeviceNameOrTypeUpdateMsg updateMsg = (DeviceNameOrTypeUpdateMsg) msg; + TransportProtos.DeviceNameOrTypeUpdateMsgProto proto = toProto(updateMsg); + return TransportProtos.ToDeviceActorNotificationMsgProto.newBuilder().setDeviceNameOrTypeMsg(proto).build(); + } else if (msg instanceof DeviceAttributesEventNotificationMsg) { + DeviceAttributesEventNotificationMsg updateMsg = (DeviceAttributesEventNotificationMsg) msg; + TransportProtos.DeviceAttributesEventMsgProto proto = toProto(updateMsg); + return TransportProtos.ToDeviceActorNotificationMsgProto.newBuilder().setDeviceAttributesEventMsg(proto).build(); + } else if (msg instanceof DeviceCredentialsUpdateNotificationMsg) { + DeviceCredentialsUpdateNotificationMsg updateMsg = (DeviceCredentialsUpdateNotificationMsg) msg; + TransportProtos.DeviceCredentialsUpdateMsgProto proto = toProto(updateMsg); + return TransportProtos.ToDeviceActorNotificationMsgProto.newBuilder().setDeviceCredentialsUpdateMsg(proto).build(); + } else if (msg instanceof ToDeviceRpcRequestActorMsg) { + ToDeviceRpcRequestActorMsg updateMsg = (ToDeviceRpcRequestActorMsg) msg; + TransportProtos.ToDeviceRpcRequestActorMsgProto proto = toProto(updateMsg); + return TransportProtos.ToDeviceActorNotificationMsgProto.newBuilder().setToDeviceRpcRequestMsg(proto).build(); + } else if (msg instanceof FromDeviceRpcResponseActorMsg) { + FromDeviceRpcResponseActorMsg updateMsg = (FromDeviceRpcResponseActorMsg) msg; + TransportProtos.FromDeviceRpcResponseActorMsgProto proto = toProto(updateMsg); + return TransportProtos.ToDeviceActorNotificationMsgProto.newBuilder().setFromDeviceRpcResponseMsg(proto).build(); + } else if (msg instanceof RemoveRpcActorMsg) { + RemoveRpcActorMsg updateMsg = (RemoveRpcActorMsg) msg; + TransportProtos.RemoveRpcActorMsgProto proto = toProto(updateMsg); + return TransportProtos.ToDeviceActorNotificationMsgProto.newBuilder().setRemoveRpcActorMsg(proto).build(); + } else if (msg instanceof DeviceDeleteMsg) { + DeviceDeleteMsg updateMsg = (DeviceDeleteMsg) msg; + TransportProtos.DeviceDeleteMsgProto proto = toProto(updateMsg); + return TransportProtos.ToDeviceActorNotificationMsgProto.newBuilder().setDeviceDeleteMsg(proto).build(); + } + return null; + } + + public static ToDeviceActorNotificationMsg fromProto(TransportProtos.ToDeviceActorNotificationMsgProto proto) { + if (proto.hasDeviceEdgeUpdateMsg()) { + return fromProto(proto.getDeviceEdgeUpdateMsg()); + } else if (proto.hasDeviceNameOrTypeMsg()) { + return fromProto(proto.getDeviceNameOrTypeMsg()); + } else if (proto.hasDeviceAttributesEventMsg()) { + return fromProto(proto.getDeviceAttributesEventMsg()); + } else if (proto.hasDeviceCredentialsUpdateMsg()) { + return fromProto(proto.getDeviceCredentialsUpdateMsg()); + } else if (proto.hasToDeviceRpcRequestMsg()) { + return fromProto(proto.getToDeviceRpcRequestMsg()); + } else if (proto.hasFromDeviceRpcResponseMsg()) { + return fromProto(proto.getFromDeviceRpcResponseMsg()); + } else if (proto.hasRemoveRpcActorMsg()) { + return fromProto(proto.getRemoveRpcActorMsg()); + } else if (proto.hasDeviceDeleteMsg()) { + return fromProto(proto.getDeviceDeleteMsg()); + } + return null; + } + + private static Set getAttributeKeySetFromProto(List deletedKeysList) { + if (deletedKeysList.isEmpty()) { + return null; + } + return deletedKeysList.stream() + .map(attributeKey -> new AttributeKey(attributeKey.getScope().name(), attributeKey.getAttributeKey())) + .collect(Collectors.toSet()); + } + + private static List getAttributesKvEntryFromProto(List valuesList) { + if (valuesList.isEmpty()) { + return null; + } + List result = new ArrayList<>(); + for (TransportProtos.AttributeValueProto kvEntry : valuesList) { + boolean hasValue = kvEntry.getHasV(); + KvEntry entry = null; + switch (kvEntry.getType()) { + case BOOLEAN_V: + entry = new BooleanDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getBoolV() : null); + break; + case LONG_V: + entry = new LongDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getLongV() : null); + break; + case DOUBLE_V: + entry = new DoubleDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getDoubleV() : null); + break; + case STRING_V: + entry = new StringDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getStringV() : null); + break; + case JSON_V: + entry = new JsonDataEntry(kvEntry.getKey(), hasValue ? kvEntry.getJsonV() : null); + break; + } + result.add(new BaseAttributeKvEntry(kvEntry.getLastUpdateTs(), entry)); + } + return result; + } +} diff --git a/common/cluster-api/src/main/proto/jsinvoke.proto b/common/proto/src/main/proto/jsinvoke.proto similarity index 97% rename from common/cluster-api/src/main/proto/jsinvoke.proto rename to common/proto/src/main/proto/jsinvoke.proto index 14712bb384..c8ddc03e2b 100644 --- a/common/cluster-api/src/main/proto/jsinvoke.proto +++ b/common/proto/src/main/proto/jsinvoke.proto @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto similarity index 74% rename from common/cluster-api/src/main/proto/queue.proto rename to common/proto/src/main/proto/queue.proto index ef6a644c7f..8c1918d5ef 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -20,6 +20,42 @@ package transport; option java_package = "org.thingsboard.server.gen.transport"; option java_outer_classname = "TransportProtos"; +/** + * Common data structures + */ +enum EntityTypeProto { + UNSPECIFIED = 0; + TENANT = 1; + CUSTOMER = 2; + USER = 3; + DASHBOARD = 4; + ASSET = 5; + DEVICE = 6; + ALARM = 7; + // next 3 reserved for PE; + RULE_CHAIN = 11; + RULE_NODE = 12; + // next 2 reserved for PE; + ENTITY_VIEW = 15; + WIDGETS_BUNDLE = 16; + WIDGET_TYPE = 17; + // next 2 reserved for PE; + TENANT_PROFILE = 20; + DEVICE_PROFILE = 21; + ASSET_PROFILE = 22; + API_USAGE_STATE = 23; + TB_RESOURCE = 24; + OTA_PACKAGE = 25; + EDGE = 26; + RPC = 27; + QUEUE = 28; + NOTIFICATION_TARGET = 29; + NOTIFICATION_TEMPLATE = 30; + NOTIFICATION_REQUEST = 31; + NOTIFICATION = 32; + NOTIFICATION_RULE = 33; +} + /** * Service Discovery Data Structures; */ @@ -96,6 +132,17 @@ message KeyValueProto { string json_v = 7; } +enum AttributeScopeProto { + CLIENT_SCOPE = 0; + SERVER_SCOPE = 1; + SHARED_SCOPE = 2; +} + +message AttributeKey { + AttributeScopeProto scope = 1; + string attributeKey = 2; +} + message AttributeValueProto { int64 lastUpdateTs = 1; KeyValueType type = 2; @@ -105,6 +152,7 @@ message AttributeValueProto { double double_v = 6; string string_v = 7; string json_v = 8; + optional string key = 9; } message TsKvProto { @@ -207,6 +255,12 @@ message GetOrCreateDeviceFromGatewayRequestMsg { message GetOrCreateDeviceFromGatewayResponseMsg { DeviceInfoProto deviceInfo = 1; bytes profileBody = 2; + TransportApiRequestErrorCode error = 3; +} + +enum TransportApiRequestErrorCode { + UNKNOWN_TRANSPORT_API_ERROR = 0; + ENTITY_LIMIT = 1; } message GetEntityProfileRequestMsg { @@ -246,6 +300,23 @@ message QueueDeleteMsg { string queueName = 5; } +message CoreStartupMsg { + string serviceId = 1; + repeated int32 partitions = 2; + int64 ts = 3; +} + +message ResourceCacheInvalidateMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + repeated ImageCacheKeyProto keys = 3; +} + +message ImageCacheKeyProto { + optional string resourceKey = 1; + optional string publicResourceKey = 2; +} + message LwM2MRegistrationRequestMsg { string tenantId = 1; string endpoint = 2; @@ -427,7 +498,7 @@ message DeviceCredentialsProto { int64 deviceIdLSB = 2; CredentialsType credentialsType = 3; string credentialsId = 4; - string credentialsValue = 5; + optional string credentialsValue = 5; } message CredentialsDataProto { @@ -555,7 +626,74 @@ message TransportToRuleEngineMsg { * TB Core Data Structures */ +message TbEntitySubEventProto { + string serviceId = 1; + int32 seqNumber = 2; + int64 tenantIdMSB = 3; + int64 tenantIdLSB = 4; + string entityType = 5; + int64 entityIdMSB = 6; + int64 entityIdLSB = 7; + string type = 8; + bool notifications = 9; + bool alarms = 10; + bool tsAllKeys = 11; + repeated string tsKeys = 12; + bool attrAllKeys = 13; + repeated string attrKeys = 14; +} + +message TbEntitySubEventCallbackProto { + int64 entityIdMSB = 1; + int64 entityIdLSB = 2; + int32 seqNumber = 3; + int64 attributesUpdateTs = 4; + int64 timeSeriesUpdateTs = 5; +} + +message TsValueProto { + int64 ts = 1; + KeyValueType type = 2; + bool bool_v = 3; + int64 long_v = 4; + double double_v = 5; + string string_v = 6; + string json_v = 7; +} + +message TsValueListProto { + string key = 1; + repeated TsValueProto tsValue = 2; +} + +message TbSubUpdateProto { + int64 entityIdMSB = 1; + int64 entityIdLSB = 2; + int32 errorCode = 3; + string errorMsg = 4; + repeated TsValueListProto data = 5; + string scope = 6; +} + +message TbAlarmSubUpdateProto { + int64 entityIdMSB = 1; + int64 entityIdLSB = 2; + int32 errorCode = 3; + string errorMsg = 4; + string alarm = 5; + bool deleted = 6; +} + +message NotificationsSubUpdateProto { + int64 entityIdMSB = 1; + int64 entityIdLSB = 2; + string notificationUpdate = 3; + string notificationRequestUpdate = 4; +} + +// DEPRECATED. FOR REMOVAL message TbSubscriptionProto { + option deprecated = true; string serviceId = 1; string sessionId = 2; int32 subscriptionId = 3; @@ -566,7 +704,9 @@ message TbSubscriptionProto { int64 entityIdLSB = 8; } +// DEPRECATED. FOR REMOVAL message TbTimeSeriesSubscriptionProto { + option deprecated = true; TbSubscriptionProto sub = 1; bool allKeys = 2; repeated TbSubscriptionKetStateProto keyStates = 3; @@ -575,28 +715,38 @@ message TbTimeSeriesSubscriptionProto { bool latestValues = 6; } +// DEPRECATED. FOR REMOVAL message TbAttributeSubscriptionProto { + option deprecated = true; TbSubscriptionProto sub = 1; bool allKeys = 2; repeated TbSubscriptionKetStateProto keyStates = 3; string scope = 4; } +// DEPRECATED. FOR REMOVAL message TbAlarmSubscriptionProto { + option deprecated = true; TbSubscriptionProto sub = 1; int64 ts = 2; } +// DEPRECATED. FOR REMOVAL message NotificationsSubscriptionProto { + option deprecated = true; TbSubscriptionProto sub = 1; int32 limit = 2; } +// DEPRECATED. FOR REMOVAL message NotificationsCountSubscriptionProto { + option deprecated = true; TbSubscriptionProto sub = 1; } +// DEPRECATED. FOR REMOVAL message TbSubscriptionUpdateProto { + option deprecated = true; string sessionId = 1; int32 subscriptionId = 2; int32 errorCode = 3; @@ -604,7 +754,9 @@ message TbSubscriptionUpdateProto { repeated TbSubscriptionUpdateValueListProto data = 5; } +// DEPRECATED. FOR REMOVAL message TbAlarmSubscriptionUpdateProto { + option deprecated = true; string sessionId = 1; int32 subscriptionId = 2; int32 errorCode = 3; @@ -613,7 +765,9 @@ message TbAlarmSubscriptionUpdateProto { bool deleted = 6; } +// DEPRECATED. FOR REMOVAL message NotificationsSubscriptionUpdateProto { + option deprecated = true; string sessionId = 1; int32 subscriptionId = 2; string notificationUpdate = 3; @@ -691,7 +845,9 @@ message TbTimeSeriesUpdateProto { repeated TsKvProto data = 6; } +// DEPRECATED. FOR REMOVAL message TbSubscriptionCloseProto { + option deprecated = true; string sessionId = 1; int32 subscriptionId = 2; } @@ -726,26 +882,32 @@ message DeviceStateServiceMsgProto { } message SubscriptionMgrMsgProto { - TbTimeSeriesSubscriptionProto telemetrySub = 1; - TbAttributeSubscriptionProto attributeSub = 2; - TbSubscriptionCloseProto subClose = 3; + TbTimeSeriesSubscriptionProto telemetrySub = 1 [deprecated = true]; // DEPRECATED. FOR REMOVAL + TbAttributeSubscriptionProto attributeSub = 2 [deprecated = true]; // DEPRECATED. FOR REMOVAL + TbSubscriptionCloseProto subClose = 3 [deprecated = true]; // DEPRECATED. FOR REMOVAL TbTimeSeriesUpdateProto tsUpdate = 4; TbAttributeUpdateProto attrUpdate = 5; TbAttributeDeleteProto attrDelete = 6; - TbAlarmSubscriptionProto alarmSub = 7; + TbAlarmSubscriptionProto alarmSub = 7 [deprecated = true]; // DEPRECATED. FOR REMOVAL TbAlarmUpdateProto alarmUpdate = 8; TbAlarmDeleteProto alarmDelete = 9; TbTimeSeriesDeleteProto tsDelete = 10; - NotificationsSubscriptionProto notificationsSub = 11; - NotificationsCountSubscriptionProto notificationsCountSub = 12; + NotificationsSubscriptionProto notificationsSub = 11 [deprecated = true]; // DEPRECATED. FOR REMOVAL + NotificationsCountSubscriptionProto notificationsCountSub = 12 [deprecated = true]; // DEPRECATED. FOR REMOVAL NotificationUpdateProto notificationUpdate = 13; NotificationRequestUpdateProto notificationRequestUpdate = 14; + TbEntitySubEventProto subEvent = 15; } message LocalSubscriptionServiceMsgProto { - TbSubscriptionUpdateProto subUpdate = 1; - TbAlarmSubscriptionUpdateProto alarmSubUpdate = 2; - NotificationsSubscriptionUpdateProto notificationsSubUpdate = 3; + TbSubscriptionUpdateProto subUpdate = 1 [deprecated = true]; // DEPRECATED. FOR REMOVAL + TbAlarmSubscriptionUpdateProto alarmSubUpdate = 2 [deprecated = true]; // DEPRECATED. FOR REMOVAL + NotificationsSubscriptionUpdateProto notificationsSubUpdate = 3 [deprecated = true]; // DEPRECATED. FOR REMOVAL + TbEntitySubEventCallbackProto subEventCallback = 4; + TbSubUpdateProto tsUpdate = 5; + TbSubUpdateProto attrUpdate = 6; + TbAlarmSubUpdateProto alarmUpdate = 7; + NotificationsSubUpdateProto notificationsUpdate = 8; } message FromDeviceRPCResponseProto { @@ -755,6 +917,27 @@ message FromDeviceRPCResponseProto { int32 error = 4; } +enum ComponentLifecycleEvent { + CREATED = 0; + STARTED = 1; + ACTIVATED = 2; + SUSPENDED = 3; + UPDATED = 4; + STOPPED = 5; + DELETED = 6; + FAILED = 7; + DEACTIVATED = 8; +} + +message ComponentLifecycleMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + EntityTypeProto entityType = 3; + int64 entityIdMSB = 4; + int64 entityIdLSB = 5; + ComponentLifecycleEvent event = 6; +} + message EdgeNotificationMsgProto { int64 tenantIdMSB = 1; int64 tenantIdLSB = 2; @@ -768,6 +951,116 @@ message EdgeNotificationMsgProto { string body = 10; PostTelemetryMsg postTelemetryMsg = 11; PostAttributeMsg postAttributesMsg = 12; + int64 originatorEdgeIdMSB = 13; + int64 originatorEdgeIdLSB = 14; +} + +message EdgeEventUpdateMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 edgeIdMSB = 3; + int64 edgeIdLSB = 4; +} + +message ToEdgeSyncRequestMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 requestIdMSB = 3; + int64 requestIdLSB = 4; + int64 edgeIdMSB = 5; + int64 edgeIdLSB = 6; +} + +message FromEdgeSyncResponseMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 responseIdMSB = 3; + int64 responseIdLSB = 4; + int64 edgeIdMSB = 5; + int64 edgeIdLSB = 6; + bool success = 7; +} + +message DeviceEdgeUpdateMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + optional int64 edgeIdMSB = 5; + optional int64 edgeIdLSB = 6; +} + +message DeviceNameOrTypeUpdateMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + string deviceName = 5; + string deviceType = 6; +} + +message DeviceAttributesEventMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + repeated AttributeKey deletedKeys = 5; + optional AttributeScopeProto scope = 6; + repeated AttributeValueProto values = 7; + bool deleted = 8; +} + +message DeviceCredentialsUpdateMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + DeviceCredentialsProto deviceCredentials = 5; +} + +message ToDeviceRpcRequestActorMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + string serviceId = 5; + ToDeviceRpcRequestMsg toDeviceRpcRequestMsg = 6; +} + +message FromDeviceRpcResponseActorMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + int32 requestId = 5; + FromDeviceRPCResponseProto rpcResponse = 6; +} + +message RemoveRpcActorMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 requestIdMSB = 3; + int64 requestIdLSB = 4; + int64 deviceIdMSB = 5; + int64 deviceIdLSB = 6; +} + +message DeviceDeleteMsgProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; +} + +message ToDeviceActorNotificationMsgProto { + DeviceEdgeUpdateMsgProto deviceEdgeUpdateMsg = 1; + DeviceNameOrTypeUpdateMsgProto deviceNameOrTypeMsg = 2; + DeviceAttributesEventMsgProto deviceAttributesEventMsg = 3; + DeviceCredentialsUpdateMsgProto deviceCredentialsUpdateMsg = 4; + ToDeviceRpcRequestActorMsgProto toDeviceRpcRequestMsg = 5; + FromDeviceRpcResponseActorMsgProto fromDeviceRpcResponseMsg = 6; + RemoveRpcActorMsgProto removeRpcActorMsg = 7; + DeviceDeleteMsgProto deviceDeleteMsg = 8; } /** @@ -995,30 +1288,38 @@ message ToCoreMsg { TransportToDeviceActorMsg toDeviceActorMsg = 1; DeviceStateServiceMsgProto deviceStateServiceMsg = 2; SubscriptionMgrMsgProto toSubscriptionMgrMsg = 3; - bytes toDeviceActorNotificationMsg = 4; + bytes toDeviceActorNotificationMsg = 4 [deprecated = true]; EdgeNotificationMsgProto edgeNotificationMsg = 5; DeviceActivityProto deviceActivityMsg = 6; NotificationSchedulerServiceMsg notificationSchedulerServiceMsg = 7; LifecycleEventProto lifecycleEventMsg = 8; ErrorEventProto errorEventMsg = 9; - DeviceConnectProto deviceConnectMsg = 10; - DeviceDisconnectProto deviceDisconnectMsg = 11; - DeviceInactivityProto deviceInactivityMsg = 12; + ToDeviceActorNotificationMsgProto toDeviceActorNotification = 10; + DeviceConnectProto deviceConnectMsg = 11; + DeviceDisconnectProto deviceDisconnectMsg = 12; + DeviceInactivityProto deviceInactivityMsg = 13; } /* High priority messages with low latency are handled by ThingsBoard Core Service separately */ +/* Please, adjust the TbCoreConsumerStats when modifying the ToCoreNotificationMsg */ message ToCoreNotificationMsg { LocalSubscriptionServiceMsgProto toLocalSubscriptionServiceMsg = 1; FromDeviceRPCResponseProto fromDeviceRpcResponse = 2; - bytes componentLifecycleMsg = 3; - bytes edgeEventUpdateMsg = 4; + bytes componentLifecycleMsg = 3 [deprecated = true]; + bytes edgeEventUpdateMsg = 4 [deprecated = true]; QueueUpdateMsg queueUpdateMsg = 5; QueueDeleteMsg queueDeleteMsg = 6; VersionControlResponseMsg vcResponseMsg = 7; - bytes toEdgeSyncRequestMsg = 8; - bytes fromEdgeSyncResponseMsg = 9; + bytes toEdgeSyncRequestMsg = 8 [deprecated = true]; + bytes fromEdgeSyncResponseMsg = 9 [deprecated = true]; SubscriptionMgrMsgProto toSubscriptionMgrMsg = 10; NotificationRuleProcessorMsg notificationRuleProcessorMsg = 11; + ComponentLifecycleMsgProto componentLifecycle = 12; + CoreStartupMsg coreStartupMsg = 13; + EdgeEventUpdateMsgProto edgeEventUpdate = 14; + ToEdgeSyncRequestMsgProto toEdgeSyncRequest = 15; + FromEdgeSyncResponseMsgProto fromEdgeSyncResponse = 16; + ResourceCacheInvalidateMsg resourceCacheInvalidateMsg = 17; } /* Messages that are handled by ThingsBoard RuleEngine Service */ @@ -1031,10 +1332,11 @@ message ToRuleEngineMsg { } message ToRuleEngineNotificationMsg { - bytes componentLifecycleMsg = 1; + bytes componentLifecycleMsg = 1 [deprecated = true]; FromDeviceRPCResponseProto fromDeviceRpcResponse = 2; QueueUpdateMsg queueUpdateMsg = 3; QueueDeleteMsg queueDeleteMsg = 4; + ComponentLifecycleMsgProto componentLifecycle = 5; } /* Messages that are handled by ThingsBoard Transport Service */ diff --git a/common/transport/transport-api/src/main/proto/transport.proto b/common/proto/src/main/proto/transport.proto similarity index 97% rename from common/transport/transport-api/src/main/proto/transport.proto rename to common/proto/src/main/proto/transport.proto index 12c7f3e8c6..b3faf24539 100644 --- a/common/transport/transport-api/src/main/proto/transport.proto +++ b/common/proto/src/main/proto/transport.proto @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. diff --git a/common/transport/transport-api/src/test/java/JsonConverterTest.java b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java similarity index 97% rename from common/transport/transport-api/src/test/java/JsonConverterTest.java rename to common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java index 39ed04a29a..773ebbea3d 100644 --- a/common/transport/transport-api/src/test/java/JsonConverterTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package org.thingsboard.server.common.adaptor; + import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import org.junit.Assert; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; import java.util.ArrayList; diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java new file mode 100644 index 0000000000..4bb98f6cd2 --- /dev/null +++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java @@ -0,0 +1,182 @@ +/** + * 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.server.common.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKey; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; +import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; +import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponseActorMsg; +import org.thingsboard.server.common.msg.rpc.RemoveRpcActorMsg; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequestActorMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceCredentialsUpdateNotificationMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceEdgeUpdateMsg; +import org.thingsboard.server.common.msg.rule.engine.DeviceNameOrTypeUpdateMsg; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProtoUtilsTest { + + TenantId tenantId = TenantId.fromUUID(UUID.fromString("35e10f77-16e7-424d-ae46-ee780f87ac4f")); + EntityId entityId = new RuleChainId(UUID.fromString("c640b635-4f0f-41e6-b10b-25a86003094e")); + DeviceId deviceId = new DeviceId(UUID.fromString("ceebb9e5-4239-437c-a507-dc5f71f1232d")); + EdgeId edgeId = new EdgeId(UUID.fromString("364be452-2183-459b-af93-1ddb325feac1")); + UUID id = UUID.fromString("31a07d85-6ed5-46f8-83c0-6715cb0a8782"); + + @Test + void protoComponentLifecycleSerialization() { + ComponentLifecycleMsg msg = new ComponentLifecycleMsg(tenantId, entityId, ComponentLifecycleEvent.UPDATED); + assertThat(ProtoUtils.fromProto(ProtoUtils.toProto(msg))).as("deserialized").isEqualTo(msg); + msg = new ComponentLifecycleMsg(tenantId, entityId, ComponentLifecycleEvent.STARTED); + assertThat(ProtoUtils.fromProto(ProtoUtils.toProto(msg))).as("deserialized").isEqualTo(msg); + } + + @Test + void protoEntityTypeSerialization() { + for (EntityType entityType : EntityType.values()) { + assertThat(ProtoUtils.fromProto(ProtoUtils.toProto(entityType))).as(entityType.getNormalName()).isEqualTo(entityType); + } + } + + @Test + void protoEdgeEventUpdateSerialization() { + EdgeEventUpdateMsg msg = new EdgeEventUpdateMsg(tenantId, edgeId); + assertThat(ProtoUtils.fromProto(ProtoUtils.toProto(msg))).as("deserialized").isEqualTo(msg); + } + + @Test + void protoToEdgeSyncRequestSerialization() { + ToEdgeSyncRequest msg = new ToEdgeSyncRequest(id, tenantId, edgeId); + assertThat(ProtoUtils.fromProto(ProtoUtils.toProto(msg))).as("deserialized").isEqualTo(msg); + } + + @Test + void protoFromEdgeSyncResponseSerialization() { + FromEdgeSyncResponse msg = new FromEdgeSyncResponse(id, tenantId, edgeId, true); + assertThat(ProtoUtils.fromProto(ProtoUtils.toProto(msg))).as("deserialized").isEqualTo(msg); + } + + @Test + void protoDeviceEdgeUpdateSerialization() { + DeviceEdgeUpdateMsg msg = new DeviceEdgeUpdateMsg(tenantId, deviceId, edgeId); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + + @Test + void protoDeviceNameOrTypeSerialization() { + String deviceName = "test", deviceType = "test"; + DeviceNameOrTypeUpdateMsg msg = new DeviceNameOrTypeUpdateMsg(tenantId, deviceId, deviceName, deviceType); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + + @Test + void protoDeviceAttributesEventSerialization() { + DeviceAttributesEventNotificationMsg msg = new DeviceAttributesEventNotificationMsg(tenantId, deviceId, null, "CLIENT_SCOPE", + List.of(new BaseAttributeKvEntry(System.currentTimeMillis(), new StringDataEntry("key", "value"))), false); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + + msg = new DeviceAttributesEventNotificationMsg(tenantId, deviceId, null, "SERVER_SCOPE", + List.of(new BaseAttributeKvEntry(System.currentTimeMillis(), new DoubleDataEntry("doubleEntry", 231.5)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new JsonDataEntry("jsonEntry", "jsonValue"))), false); + serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + + msg = new DeviceAttributesEventNotificationMsg(tenantId, deviceId, null, "SERVER_SCOPE", + List.of(new BaseAttributeKvEntry(System.currentTimeMillis(), new DoubleDataEntry("entry", 11.3)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new BooleanDataEntry("jsonEntry", true))), false); + serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + + msg = new DeviceAttributesEventNotificationMsg(tenantId, deviceId, Set.of(new AttributeKey("SHARED_SCOPE", "attributeKey")), null, null, true); + serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + + @Test + void protoDeviceCredentialsUpdateSerialization() { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(deviceId); + deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + deviceCredentials.setCredentialsValue("test"); + deviceCredentials.setCredentialsId("test"); + DeviceCredentialsUpdateNotificationMsg msg = new DeviceCredentialsUpdateNotificationMsg(tenantId, deviceId, deviceCredentials); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + + @Test + void protoToDeviceRpcRequestSerialization() { + String serviceId = "cadcaac6-85c3-4211-9756-f074dcd1e7f7"; + ToDeviceRpcRequest request = new ToDeviceRpcRequest(id, tenantId, deviceId, true, 0, new ToDeviceRpcRequestBody("method", "params"), false, 0, ""); + ToDeviceRpcRequestActorMsg msg = new ToDeviceRpcRequestActorMsg(serviceId, request); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + + @Test + void protoFromDeviceRpcResponseSerialization() { + FromDeviceRpcResponseActorMsg msg = new FromDeviceRpcResponseActorMsg(23, tenantId, deviceId, new FromDeviceRpcResponse(id, "response", RpcError.NOT_FOUND)); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + + @Test + void protoRemoveRpcActorSerialization() { + RemoveRpcActorMsg msg = new RemoveRpcActorMsg(tenantId, deviceId, id); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } +} diff --git a/common/queue/pom.xml b/common/queue/pom.xml index 6a03c6bff3..1eb4dd6ce8 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -1,6 +1,6 @@ + + + + + /var/log/thingsboard/${TB_SERVICE_ID}/thingsboard.log + + /var/log/thingsboard/${TB_SERVICE_ID}/thingsboard.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + diff --git a/msa/black-box-tests/src/test/resources/tb-transports/coap/conf/logback.xml b/msa/black-box-tests/src/test/resources/tb-transports/coap/conf/logback.xml new file mode 100644 index 0000000000..2f274fce90 --- /dev/null +++ b/msa/black-box-tests/src/test/resources/tb-transports/coap/conf/logback.xml @@ -0,0 +1,57 @@ + + + + + + + /var/log/tb-coap-transport/${TB_SERVICE_ID}/tb-coap-transport.log + + /var/log/tb-coap-transport/${TB_SERVICE_ID}/tb-coap-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/msa/black-box-tests/src/test/resources/tb-transports/http/conf/logback.xml b/msa/black-box-tests/src/test/resources/tb-transports/http/conf/logback.xml new file mode 100644 index 0000000000..0408be6bd3 --- /dev/null +++ b/msa/black-box-tests/src/test/resources/tb-transports/http/conf/logback.xml @@ -0,0 +1,57 @@ + + + + + + + /var/log/tb-http-transport/${TB_SERVICE_ID}/tb-http-transport.log + + /var/log/tb-http-transport/${TB_SERVICE_ID}/tb-http-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/msa/black-box-tests/src/test/resources/tb-transports/mqtt/conf/logback.xml b/msa/black-box-tests/src/test/resources/tb-transports/mqtt/conf/logback.xml new file mode 100644 index 0000000000..735a3e543e --- /dev/null +++ b/msa/black-box-tests/src/test/resources/tb-transports/mqtt/conf/logback.xml @@ -0,0 +1,55 @@ + + + + + + + /var/log/tb-mqtt-transport/${TB_SERVICE_ID}/tb-mqtt-transport.log + + /var/log/tb-mqtt-transport/${TB_SERVICE_ID}/tb-mqtt-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/msa/black-box-tests/src/test/resources/uiTests.xml b/msa/black-box-tests/src/test/resources/uiTests.xml index e9bea8b1ef..db8b41bd25 100644 --- a/msa/black-box-tests/src/test/resources/uiTests.xml +++ b/msa/black-box-tests/src/test/resources/uiTests.xml @@ -1,7 +1,7 @@ - diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss index 3aed0ccb5e..505db79a36 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - .user-avatar { display: inline-flex; justify-content: center; diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.ts index 1fb1d3f689..0fc489fbfe 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html index 8618592ae0..8145c31b06 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html @@ -1,6 +1,6 @@ -
- + {{ getSortDirectionIcon() }} + +
- - - - - Windows - - - - - - - - - - Linux - - - - - - - - - - MacOS - - - - - - - -
- -
-
device.connectivity.execute-following-command
+
+
gateway.launch-gateway
+
gateway.launch-docker-compose
- +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.scss index befa034375..6a1ea79a80 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -14,28 +14,35 @@ * limitations under the License. */ :host { - width: 100%; - height: 100%; - display: block; + .tb-commands-hint { + color: inherit; + font-weight: normal; + flex: 1; + } } :host ::ng-deep { .tb-markdown-view { .start-code { - code[class*="language-"] { - white-space: break-spaces; - word-break: break-all; - } - pre[class*="language-"] { - overflow: hidden; - background: #F3F6FA; - border-color: #305680; - } .code-wrapper { padding: 0; + + pre[class*=language-] { + margin: 0; + background: #F3F6FA; + border-color: #305680; + padding-right: 38px; + overflow: scroll; + padding-bottom: 4px; + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + } } button.clipboard-btn { - right: 0; + right: -2px; p { color: #305680; } @@ -60,9 +67,5 @@ } } } - - .tb-form-panel.tb-tab-body { - margin-top: 16px; - } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.ts index d5516dd59b..8465b5879d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/device-gateway-command.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -14,11 +14,8 @@ /// limitations under the License. /// -import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { DeviceService } from '@core/http/device.service'; -import { helpBaseUrl } from '@shared/models/constants'; -import { getOS } from '@core/utils'; -import { PublishLaunchCommand } from '@shared/models/device.models'; @Component({ selector: 'tb-gateway-command', @@ -26,47 +23,20 @@ import { PublishLaunchCommand } from '@shared/models/device.models'; styleUrls: ['./device-gateway-command.component.scss'] }) -export class DeviceGatewayCommandComponent implements OnInit { - - @Input() - token: string; +export class DeviceGatewayCommandComponent { @Input() deviceId: string; - commands: PublishLaunchCommand; - - helpLink: string = helpBaseUrl + '/docs/iot-gateway/install/docker-installation/'; - - tabIndex = 0; - - constructor(private cd: ChangeDetectorRef, - private deviceService: DeviceService) { + constructor(private deviceService: DeviceService) { } - - ngOnInit(): void { - if (this.deviceId) { - this.deviceService.getDevicePublishLaunchCommands(this.deviceId).subscribe(commands => { - this.commands = commands; - this.cd.detectChanges(); - }); + download($event: Event) { + if ($event) { + $event.stopPropagation(); } - const currentOS = getOS(); - switch (currentOS) { - case 'linux': - case 'android': - this.tabIndex = 1; - break; - case 'macos': - case 'ios': - this.tabIndex = 2; - break; - case 'windows': - this.tabIndex = 0; - break; - default: - this.tabIndex = 1; + if (this.deviceId) { + this.deviceService.downloadGatewayDockerComposeFile(this.deviceId).subscribe(() => {}); } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html index 7ddf0c8cd9..153b6b0e14 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html @@ -1,6 +1,6 @@ -
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.scss index e704120560..cd7722d12d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.ts index 503f5643bf..a3cb79e125 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-statistics.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts index d58c33079a..603b06cc01 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-widget.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.html index 1eaf791577..7f86897f7e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.html @@ -1,6 +1,6 @@ -
+
@@ -25,7 +25,7 @@
+ [style.background-size]="vertical ? '100% ' + (batteryFillValue + 1) + '%' : (batteryFillValue + 1) + '% 100%'">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.scss index fbf244a383..444417fb09 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.ts index a2793234f4..5251a0c602 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -46,6 +46,9 @@ import { BatteryLevelWidgetSettings } from '@home/components/widget/lib/indicator/battery-level-widget.models'; import { ResizeObserver } from '@juggle/resize-observer'; +import { Observable } from 'rxjs'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; const verticalBatteryDimensions = { shapeAspectRatio: 64 / 113, @@ -117,6 +120,8 @@ export class BatteryLevelWidgetComponent implements OnInit, OnDestroy, AfterView value: number; + batteryFillValue: number; + batterySections: boolean[]; dividedBorderRadius: string; dividedGap: string; @@ -125,7 +130,7 @@ export class BatteryLevelWidgetComponent implements OnInit, OnDestroy, AfterView batteryShapeColor: ColorProcessor; - backgroundStyle: ComponentStyle = {}; + backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; batteryBoxResize$: ResizeObserver; @@ -136,6 +141,8 @@ export class BatteryLevelWidgetComponent implements OnInit, OnDestroy, AfterView private units = ''; constructor(private date: DatePipe, + private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, private widgetComponent: WidgetComponent, private renderer: Renderer2, private cd: ChangeDetectorRef) { @@ -183,14 +190,14 @@ export class BatteryLevelWidgetComponent implements OnInit, OnDestroy, AfterView this.showValue = this.settings.showValue; this.autoScaleValueSize = this.showValue && this.settings.autoScaleValueSize; - this.valueStyle = textStyle(this.settings.valueFont, '0.1px'); + this.valueStyle = textStyle(this.settings.valueFont); this.valueColor = ColorProcessor.fromSettings(this.settings.valueColor); this.batteryLevelColor = ColorProcessor.fromSettings(this.settings.batteryLevelColor); this.batteryShapeColor = ColorProcessor.fromSettings(this.settings.batteryShapeColor); - this.backgroundStyle = backgroundStyle(this.settings.background); + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0; @@ -221,10 +228,11 @@ export class BatteryLevelWidgetComponent implements OnInit, OnDestroy, AfterView public onDataUpdated() { const tsValue = getSingleTsValue(this.ctx.data); - this.value = 0; + this.batteryFillValue = 0; if (tsValue && isDefinedAndNotNull(tsValue[1]) && isNumeric(tsValue[1])) { this.value = tsValue[1]; - this.valueText = formatValue(this.value, this.decimals, this.units, true); + this.batteryFillValue = this.parseBatteryFillValue(this.value); + this.valueText = formatValue(this.value, this.decimals, this.units, false); } else { this.valueText = 'N/A'; } @@ -240,6 +248,16 @@ export class BatteryLevelWidgetComponent implements OnInit, OnDestroy, AfterView this.cd.detectChanges(); } + parseBatteryFillValue(value: number) { + if (value < 0) { + return 0; + } else if (value > 100) { + return 100; + } else { + return value; + } + } + public trackBySection(index: number): number { return index; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.models.ts index 34c00a4b92..081aae6558 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -78,22 +78,22 @@ export const batteryLevelDefaultSettings: BatteryLevelWidgetSettings = { }, valueColor: constantColor('rgba(0, 0, 0, 0.87)'), batteryLevelColor: { - color: 'rgba(92, 223, 144, 1)', + color: 'rgba(224, 224, 224, 1)', type: ColorType.range, rangeList: [ - {from: 0, to: 25, color: 'rgba(227, 71, 71, 1)'}, + {from: null, to: 25, color: 'rgba(227, 71, 71, 1)'}, {from: 25, to: 50, color: 'rgba(246, 206, 67, 1)'}, - {from: 50, to: 100, color: 'rgba(92, 223, 144, 1)'} + {from: 50, to: null, color: 'rgba(92, 223, 144, 1)'} ], colorFunction: defaultColorFunction }, batteryShapeColor: { - color: 'rgba(92, 223, 144, 0.32)', + color: 'rgba(224, 224, 224, 0.32)', type: ColorType.range, rangeList: [ - {from: 0, to: 25, color: 'rgba(227, 71, 71, 0.32)'}, + {from: null, to: 25, color: 'rgba(227, 71, 71, 0.32)'}, {from: 25, to: 50, color: 'rgba(246, 206, 67, 0.32)'}, - {from: 50, to: 100, color: 'rgba(92, 223, 144, 0.32)'} + {from: 50, to: null, color: 'rgba(92, 223, 144, 0.32)'} ], colorFunction: defaultColorFunction }, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.html new file mode 100644 index 0000000000..5c247ca794 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.html @@ -0,0 +1,29 @@ + +
+
+ +
+
+
+
+ {{ error }} +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.scss new file mode 100644 index 0000000000..360a75f54c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.scss @@ -0,0 +1,72 @@ +/** + * 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-liquid-level-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 24px 24px 24px; + &.tb-liquid-level-pointer { + cursor: pointer; + } + > div:not(.tb-liquid-level-overlay) { + z-index: 1; + } + .tb-liquid-level-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + div.tb-widget-title { + padding: 0; + } + .tb-liquid-level-content { + min-height: 0; + flex: 1; + display: flex; + justify-content: center; + } + .tb-liquid-level-error { + position: absolute; + display: flex; + inset: 0; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + + &-container { + border-radius: 4px; + margin: 16px; + padding: 8px 12px; + background: #848484; + display: flex; + flex-direction: column; + gap: 8px; + + & > * { + font-weight: 500; + letter-spacing: .25px; + text-align: center; + color: #fff; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts new file mode 100644 index 0000000000..3c79883bf3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts @@ -0,0 +1,584 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnInit, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { isDefined, isDefinedAndNotNull, isEmptyStr, isNotEmptyStr, isNumeric, isUndefinedOrNull } from '@core/utils'; +import { + CapacityUnits, + ConversionType, + convertLiters, + createAbsoluteLayout, + createPercentLayout, + extractValue, + levelCardDefaultSettings, + LevelCardLayout, + LevelCardWidgetSettings, + LiquidWidgetDataSourceType, + Shapes, + SvgInfo, + SvgLimits, + svgMapping +} from '@home/components/widget/lib/indicator/liquid-level-widget.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { EntityId } from '@shared/models/id/entity-id'; +import { + backgroundStyle, + ColorProcessor, + ComponentStyle, + cssTextFromInlineStyle, + DateFormatProcessor, + inlineTextStyle, + overlayStyle +} from '@shared/models/widget-settings.models'; +import { ResourcesService } from '@core/services/resources.service'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { TranslateService } from '@ngx-translate/core'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { DataEntry } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-liquid-level-widget', + templateUrl: './liquid-level-widget.component.html', + styleUrls: ['./liquid-level-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class LiquidLevelWidgetComponent implements OnInit { + + @ViewChild('liquidLevelContent', {static: true}) + liquidLevelContent: ElementRef; + + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + + hasCardClickAction = false; + + errorsMsg: string[] = []; + + private svgParams: SvgInfo; + + private svg: JQuery; + private tooltip: ITooltipsterInstance; + private overlayContainer: JQuery; + private shape: Shapes; + + private settings: LevelCardWidgetSettings; + + private tankColor: ColorProcessor; + private valueColor: ColorProcessor; + private liquidColor: ColorProcessor; + private backgroundOverlayColor: ColorProcessor; + private tooltipLevelColor: ColorProcessor; + + private tooltipDateFormat: DateFormatProcessor; + + private volume: number; + private tooltipContent: string; + private widgetUnits: string; + + private capacityUnits = Object.values(CapacityUnits); + + constructor(private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, + private cd: ChangeDetectorRef, + private resourcesService: ResourcesService, + private translate: TranslateService) { + } + + ngOnInit(): void { + this.ctx.$scope.liquidLevelWidget = this; + this.settings = {...levelCardDefaultSettings, ...this.ctx.settings}; + this.declareStyles(); + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + + this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0; + + this.getData().subscribe(data => { + if (data) { + const { svg, volume, units } = data; + if (svg && isNotEmptyStr(svg) && this.liquidLevelContent.nativeElement) { + const jQueryContainerElement = $(this.liquidLevelContent.nativeElement); + jQueryContainerElement.html(svg); + this.svg = jQueryContainerElement.find('svg'); + this.createSVG(); + this.createValueElement(); + + if (this.settings.showTooltip && (this.settings.showTooltipLevel || this.settings.showTooltipDate)) { + this.createTooltip(); + } + } + + if (isDefined(volume) && !isNaN(Number(volume))) { + this.volume = Number(volume); + } + + if (units) { + this.widgetUnits = units; + } + + this.update(true); + } + }); + } + + private declareStyles(): void { + this.tankColor = ColorProcessor.fromSettings(this.settings.tankColor); + this.valueColor = ColorProcessor.fromSettings(this.settings.valueColor); + this.liquidColor = ColorProcessor.fromSettings(this.settings.liquidColor); + this.backgroundOverlayColor = ColorProcessor.fromSettings(this.settings.backgroundOverlayColor); + this.tooltipLevelColor = ColorProcessor.fromSettings(this.settings.tooltipLevelColor); + + this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat); + } + + private getData(): Observable<{ svg: string; volume: number; units: string }> { + if (this.ctx.datasources?.length) { + const entityId: EntityId = { + entityType: this.ctx.datasources[0].entityType, + id: this.ctx.datasources[0].entityId + }; + + return this.getShape(entityId).pipe( + switchMap(shape => { + this.shape = shape; + this.svgParams = svgMapping.get(shape); + if (this.svgParams) { + return forkJoin([ + this.resourcesService.loadJsonResource(this.svgParams.svg), + this.getTankersParams(entityId) + ]).pipe( + map(params => ({svg: params[0], ...params[1]})) + ); + } + return of(null); + }) + ); + } + + return of(null); + } + + public onInit() { + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + + public update(ignoreAnimation?: boolean) { + if (this.svg) { + this.updateData(ignoreAnimation); + } + } + + public destroy() { + + } + + private createSVG() { + if (this.svg) { + this.svg.css('display', 'block') + .css('overflow', 'hidden') + .css('width', '100%') + .css('height', '100%'); + } + } + + private updateData(ignoreAnimation?: boolean) { + const data = this.ctx.data[0]?.data[0]; + if (data && isDefinedAndNotNull(data[1])) { + const percentage = isNumeric(data[1]) ? this.convertInputData(data[1]) : 0; + this.updateSvg(percentage, ignoreAnimation); + this.updateValueElement(data[1], percentage); + + if (this.settings.showTooltip && (this.settings.showTooltipLevel || this.settings.showTooltipDate)) { + this.updateTooltip(data); + } + } + } + + private createTooltip(): void { + this.tooltipContent = this.getTooltipContent(); + + import('tooltipster').then(() => { + if ($.tooltipster && this.tooltip) { + this.tooltip.destroy(); + this.tooltip = null; + } + this.tooltip = this.svg.tooltipster({ + contentAsHTML: true, + theme: 'tooltipster-shadow', + side: 'top', + delay: 10, + distance: this.settings.showTitle ? -33 : -63, + triggerClose: { + click: true, + tap: true, + scroll: true, + mouseleave: true + }, + animation: 'grow', + updateAnimation: null, + trackOrigin: true, + functionBefore: (instance, helper) => { + instance.content(this.tooltipContent); + }, + functionReady: (instance, helper) => { + const tooltipsterBoxStyles: JQuery.PlainObject = { + backgroundColor: this.settings.tooltipBackgroundColor, + backdropFilter: `blur(${this.settings.tooltipBackgroundBlur}px)`, + width: '100%', + height: '100%' + }; + + $(helper.tooltip).css('max-width', this.svg.width() + 'px'); + $(helper.tooltip).css('max-height', this.svg.height() + 'px'); + $(helper.tooltip).find('.tooltipster-box').css(tooltipsterBoxStyles); + $(helper.tooltip).find('.tooltipster-arrow').empty(); + + instance.reposition(); + } + }).tooltipster('instance'); + }); + } + + private createValueElement(): void { + const jQueryContainerElement = $(this.liquidLevelContent.nativeElement); + const containerOverlay = jQueryContainerElement.find('.container-overlay'); + const percentageOverlay = jQueryContainerElement.find('.percentage-overlay'); + const absoluteOverlay = jQueryContainerElement.find('.absolute-overlay'); + + if (this.settings.layout === LevelCardLayout.absolute && !this.errorsMsg.length) { + this.overlayContainer = absoluteOverlay; + percentageOverlay.css('visibility', 'hidden'); + if (!this.settings.showBackgroundOverlay) { + absoluteOverlay.css('visibility', 'hidden'); + } + } else if (this.settings.layout === LevelCardLayout.percentage && !this.errorsMsg.length) { + this.overlayContainer = percentageOverlay; + absoluteOverlay.css('visibility', 'hidden'); + if (!this.settings.showBackgroundOverlay) { + percentageOverlay.css('visibility', 'hidden'); + } + } else { + containerOverlay.css('visibility', 'hidden'); + } + + if (this.overlayContainer) { + this.overlayContainer.attr('fill', this.backgroundOverlayColor.color); + this.overlayContainer.removeAttr('fill-opacity'); + } + } + + private getShape(entityId: EntityId): Observable { + if (this.settings.tankSelectionType === LiquidWidgetDataSourceType.attribute && entityId.id !== NULL_UUID) { + return this.ctx.attributeService.getEntityAttributes(entityId, null, [this.settings.shapeAttributeName]) + .pipe(map(attributes => { + const shape = extractValue(attributes, this.settings.shapeAttributeName); + if (!shape || !svgMapping.has(shape)) { + this.createdErrorMgs(this.settings.shapeAttributeName, isUndefinedOrNull(shape) || isEmptyStr(shape)); + return this.settings.selectedShape; + } + return shape; + } + )); + } + return of(this.settings.selectedShape); + } + + private getTankersParams(entityId: EntityId): Observable<{ volume: number; units: string }> { + const isVolumeStatic = this.settings.layout !== LevelCardLayout.absolute + && this.settings.datasourceUnits === CapacityUnits.percent + || this.settings.volumeSource === LiquidWidgetDataSourceType.static; + const isUnitStatic = this.settings.layout !== LevelCardLayout.absolute || + this.settings.widgetUnitsSource === LiquidWidgetDataSourceType.static; + + const attributeKeys: string[] = []; + + if (!isVolumeStatic) { + attributeKeys.push(this.settings.volumeAttributeName); + } + + if (!isUnitStatic) { + attributeKeys.push(this.settings.widgetUnitsAttributeName); + } + + if (!attributeKeys.length || entityId.id === NULL_UUID) { + return of({ + volume: this.settings.volumeConstant, + units: this.settings.units + }); + } + + return this.ctx.attributeService.getEntityAttributes(entityId, null, attributeKeys).pipe( + map(attributes => { + let volume = isVolumeStatic ? this.settings.volumeConstant : extractValue(attributes, this.settings.volumeAttributeName); + let units = isUnitStatic ? this.settings.units : extractValue(attributes, this.settings.widgetUnitsAttributeName); + + if (!isVolumeStatic && (!volume || !isNumeric(volume) || volume < 0.1)) { + this.createdErrorMgs(this.settings.volumeAttributeName, isUndefinedOrNull(volume) || isEmptyStr(volume)); + volume = this.settings.volumeConstant; + } + + if (!isUnitStatic) { + if (isNotEmptyStr(units)) { + const normalizeUnits = units.normalize().trim(); + units = this.capacityUnits.find(unit => unit.normalize() === normalizeUnits); + } + if (isUndefinedOrNull(units) || !isNotEmptyStr(units)) { + this.createdErrorMgs(this.settings.widgetUnitsAttributeName, isUndefinedOrNull(units) || isEmptyStr(units)); + units = this.settings.units; + } + } + + return { + volume, + units + }; + }) + ); + } + + private createdErrorMgs(attributeName: string, isEmpty = false) { + if (isEmpty) { + this.errorsMsg.push(this.translate.instant('widgets.liquid-level-card.attribute-key-not-set', {attributeName})); + } else { + this.errorsMsg.push(this.translate.instant('widgets.liquid-level-card.attribute-key-invalid', {attributeName})); + } + this.cd.markForCheck(); + } + + private updateSvg(percentage: number, ignoreAnimation?: boolean) { + const yLimits: SvgLimits = { + min: this.svgParams.limits.min, + max: this.svgParams.limits.max + }; + + const newYPos = this.calculatePosition(percentage, yLimits); + this.updateShapeColor(percentage); + this.updateLevel(newYPos, percentage, ignoreAnimation); + } + + private calculatePosition(percentage: number, limits: SvgLimits): number { + if (percentage >= 100) { + return limits.max; + } if (percentage <= 0) { + return limits.min; + } + return limits.min + (percentage / 100) * (limits.max - limits.min); + } + + private updateTooltip(value: DataEntry): void { + this.tooltipContent = this.getTooltipContent(value); + + if (this.tooltip) { + this.tooltip.content(this.tooltipContent); + } + } + + private updateLevel(newY: number, percentage: number, ignoreAnimation = false): void { + this.liquidColor.update(percentage); + const jQueryContainerElement = $(this.liquidLevelContent.nativeElement); + const fill = jQueryContainerElement.find('.tb-liquid-fill'); + const surfaces = jQueryContainerElement.find('.tb-liquid-surface'); + const surfacePositionAttr = this.shape !== Shapes.vCylinder ? 'y' : 'cy'; + const animationSpeed = 500; + + const levelColor = this.errorsMsg.length ? 'transparent' : this.liquidColor.color; + + if (ignoreAnimation) { + fill.css({y : newY}); + } else { + fill.animate({y : newY}, animationSpeed); + } + fill.attr('fill', levelColor); + + surfaces.each((index, element) => { + const $element = $(element); + if (ignoreAnimation) { + $element.css({[surfacePositionAttr]: newY}); + } else { + $element.animate({[surfacePositionAttr]: newY}, animationSpeed); + } + if ($element.hasClass('tb-liquid')) { + $element.attr('fill', levelColor); + } + }); + } + + private updateShapeColor(value: number): void { + const jQueryContainerElement = $(this.liquidLevelContent.nativeElement); + const shapeStrokes = jQueryContainerElement.find('.tb-shape-stroke'); + const shapeFill = jQueryContainerElement.find('.tb-shape-fill'); + this.tankColor.update(value); + + const shapeColor = this.errorsMsg.length ? '#CACACA' : this.tankColor.color; + + shapeStrokes.each((index, element) => { + $(element).attr('stroke', shapeColor); + }); + + shapeFill.each((index, element) => { + $(element).attr('fill', shapeColor); + }); + } + + private updateValueElement(data: any, percentage: number): void { + let content: string; + let container: JQuery; + const jQueryContainerElement = $(this.liquidLevelContent.nativeElement); + let value = 'N/A'; + + if (isNumeric(data)) { + value = convertLiters(this.convertOutputData(percentage), this.widgetUnits as CapacityUnits, ConversionType.from) + .toFixed(this.settings.decimals || 0); + } + this.valueColor.update(value); + const valueTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.valueFont), + color: this.valueColor.color}); + this.backgroundOverlayColor.update(percentage); + if (this.overlayContainer) { + this.overlayContainer.attr('fill', this.backgroundOverlayColor.color); + } + + if (this.settings.layout === LevelCardLayout.absolute) { + const volumeInLiters: number = convertLiters(this.volume, this.settings.volumeUnits as CapacityUnits, ConversionType.to); + const volume = convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from) + .toFixed(this.settings.decimals || 0); + + const volumeTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.volumeFont), + color: this.settings.volumeColor}); + + container = jQueryContainerElement.find('.absolute-value-container'); + content = createAbsoluteLayout({inputValue: value, volume}, + {valueStyle: valueTextStyle, volumeStyle: volumeTextStyle}, this.widgetUnits); + + } else if (this.settings.layout === LevelCardLayout.percentage) { + container = jQueryContainerElement.find('.percentage-value-container'); + content = createPercentLayout(value, valueTextStyle); + } + + if (content && container) { + container.html(content); + } + } + + private getTooltipContent(value?: DataEntry): string { + const contentValue = value || [0, '']; + let tooltipValue: string | number = 'N/A'; + + if (isNumeric(contentValue[1])) { + tooltipValue = this.convertTooltipData(contentValue[1]); + } + + this.tooltipLevelColor.update(tooltipValue); + this.tooltipDateFormat.update(contentValue[0]); + + const levelTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.tooltipLevelFont), + color: this.tooltipLevelColor.color}); + + const dateTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.tooltipDateFont), + color: this.settings.tooltipDateColor, overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap'}); + + let content = `
`; + + if (this.settings.showTooltipLevel) { + const levelValue = typeof tooltipValue == 'number' + ? `${tooltipValue.toFixed(this.settings.tooltipLevelDecimals)} ${this.settings.tooltipUnits}` + : 'N/A'; + content += this.createTooltipContent( + this.ctx.translate.instant('widgets.liquid-level-card.level'), + levelValue, + levelTextStyle + ); + } + + if (this.settings.showTooltipDate) { + content += this.createTooltipContent( + this.ctx.translate.instant('widgets.liquid-level-card.last-update'), + this.tooltipDateFormat.formatted, + dateTextStyle + ); + } + + content += '
'; + + return content; + } + + private createTooltipContent(labelText: string, contentValue: string, textStyle: string): string { + return `
+ + +
`; + } + + private convertInputData(value: any): number { + if (this.settings.datasourceUnits !== CapacityUnits.percent) { + return (convertLiters(Number(value), this.settings.datasourceUnits, ConversionType.to) / + convertLiters(this.volume, this.settings.volumeUnits, ConversionType.to)) * 100; + } + + return Number(value); + } + + private convertOutputData(value: number): number { + if (this.widgetUnits !== CapacityUnits.percent) { + return convertLiters(this.volume * (value / 100), this.settings.volumeUnits, ConversionType.to); + } + + return value; + } + + private convertTooltipData(value: number): number { + const percentage = this.convertInputData(value); + if (this.settings.tooltipUnits !== CapacityUnits.percent) { + const liters = this.convertOutputData(percentage); + + return convertLiters(liters, this.settings.tooltipUnits, ConversionType.from); + } else { + return percentage; + } + } + + public cardClick($event: Event) { + this.ctx.actionsApi.cardClick($event); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.models.ts new file mode 100644 index 0000000000..954e228e79 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.models.ts @@ -0,0 +1,623 @@ +/// +/// 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, + cssTextFromInlineStyle, + DateFormatSettings, + Font, + lastUpdateAgoDateFormat +} from '@shared/models/widget-settings.models'; +import { DataKey, WidgetConfig } from '@shared/models/widget.models'; +import { AttributeData, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { singleEntityFilterFromDeviceId } from '@shared/models/query/query.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { catchError, map, mergeMap } from 'rxjs/operators'; +import { EntityService } from '@core/http/entity.service'; +import { IAliasController } from '@core/api/widget-api.models'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { ResourcesService } from '@core/services/resources.service'; +import { FormGroup } from '@angular/forms'; + +export interface SvgInfo { + svg: string; + limits: SvgLimits; +} + +export interface SvgLimits { + min: number; + max: number; +} + +export interface LevelCardWidgetSettings extends WidgetConfig { + tankSelectionType: LiquidWidgetDataSourceType; + selectedShape: Shapes; + shapeAttributeName: string; + tankColor: ColorSettings; + datasourceUnits: CapacityUnits; + layout: LevelCardLayout; + volumeSource: LiquidWidgetDataSourceType; + volumeConstant: number; + volumeAttributeName: string; + volumeUnits: CapacityUnits; + volumeFont: Font; + volumeColor: string; + liquidColor: ColorSettings; + valueFont: Font; + widgetUnitsSource: LiquidWidgetDataSourceType; + widgetUnitsAttributeName: string; + valueColor: ColorSettings; + showBackgroundOverlay: boolean; + backgroundOverlayColor: ColorSettings; + showTooltip: boolean; + showTooltipLevel: boolean; + tooltipUnits: CapacityUnits; + tooltipLevelDecimals: number; + tooltipLevelFont: Font; + tooltipLevelColor: ColorSettings; + showTooltipDate: boolean; + tooltipDateFormat: DateFormatSettings; + tooltipDateFont: Font; + tooltipDateColor: string; + tooltipBackgroundColor: string; + tooltipBackgroundBlur: number; + background: BackgroundSettings; +} + +export enum Shapes { + vOval = 'Vertical Oval', + vCylinder = 'Vertical Cylinder', + vCapsule = 'Vertical Capsule', + rectangle = 'Rectangle', + hOval = 'Horizontal Oval', + hEllipse = 'Horizontal Ellipse', + hDishEnds = 'Horizontal Dish Ends', + hCylinder = 'Horizontal Cylinder', + hCapsule = 'Horizontal Capsule', + hElliptical_2_1 = 'Horizontal 2:1 Elliptical' +} + +export enum CapacityUnits { + percent = '%', + liters = 'L', + cubicMillimeter = 'mm³', + cubicCentimeter = 'cm³', + cubicMeter = 'm³', + cubicKilometer = 'km³', + milliliter = 'mL', + hectoliter = 'hl', + cubicInch = 'in³', + cubicFoot = 'ft³', + cubicYard = 'yd³', + fluidOunce = 'fl-oz', + pint = 'pt', + quart = 'qt', + gallon = 'gal', + oilBarrels = 'bbl' +} + +export enum LevelCardLayout { + simple = 'simple', + percentage = 'percentage', + absolute = 'absolute' +} + +export enum LiquidWidgetDataSourceType { + static = 'static', + attribute = 'attribute' +} + +export enum ConversionType { + to = 'to', + from = 'from', +} + +export const svgMapping = new Map( + [ + [ + Shapes.vOval, + { + svg: 'assets/widget/liquid-level/shapes/vertical-oval.svg', + limits: { min: 171, max: 51 } + } + ], + [ + Shapes.vCylinder, + { + svg: 'assets/widget/liquid-level/shapes/vertical-cylinder.svg', + limits: { min: 161, max: 55 } + } + ], + [ + Shapes.vCapsule, + { + svg: 'assets/widget/liquid-level/shapes/vertical-capsule.svg', + limits: { min: 197, max: 25 } + } + ], + [ + Shapes.rectangle, + { + svg: 'assets/widget/liquid-level/shapes/rectangle.svg', + limits: { min: 169, max: 52 } + } + ], + [ + Shapes.hOval, + { + svg: 'assets/widget/liquid-level/shapes/horizontal-oval.svg', + limits: { min: 160, max: 63 } + } + ], + [ + Shapes.hEllipse, + { + svg: 'assets/widget/liquid-level/shapes/horizontal-ellipse.svg', + limits: { min: 160, max: 64 } + } + ], + [ + Shapes.hDishEnds, + { + svg: 'assets/widget/liquid-level/shapes/horizontal-dish-ends.svg', + limits: { min: 173, max: 50 } + } + ], + [ + Shapes.hCylinder, + { + svg: 'assets/widget/liquid-level/shapes/horizontal-cylinder.svg', + limits: { min: 165, max: 56 } + } + ], + [ + Shapes.hCapsule, + { + svg: 'assets/widget/liquid-level/shapes/horizontal-capsule.svg', + limits: { min: 171, max: 52 } + } + ], + [ + Shapes.hElliptical_2_1, + { + svg: 'assets/widget/liquid-level/shapes/horizontal-2_1-elliptical.svg', + limits: { min: 173, max: 50 } + } + ] + ] +); + +export const levelCardLayoutTranslations = new Map( + [ + [LevelCardLayout.simple, 'widgets.liquid-level-card.layout-simple'], + [LevelCardLayout.percentage, 'widgets.liquid-level-card.layout-percentage'], + [LevelCardLayout.absolute, 'widgets.liquid-level-card.layout-absolute'] + ] +); + +export const LiquidWidgetDataSourceTypeTranslations = new Map( + [ + [LiquidWidgetDataSourceType.static, 'widgets.liquid-level-card.static'], + [LiquidWidgetDataSourceType.attribute, 'widgets.liquid-level-card.attribute'] + ] +); + +export const ShapesTranslations = new Map( + [ + [Shapes.vOval, 'widgets.liquid-level-card.v-oval'], + [Shapes.vCylinder, 'widgets.liquid-level-card.v-cylinder'], + [Shapes.vCapsule, 'widgets.liquid-level-card.v-capsule'], + [Shapes.rectangle, 'widgets.liquid-level-card.rectangle'], + [Shapes.hOval, 'widgets.liquid-level-card.h-oval'], + [Shapes.hEllipse, 'widgets.liquid-level-card.h-ellipse'], + [Shapes.hDishEnds, 'widgets.liquid-level-card.h-dish-ends'], + [Shapes.hCylinder, 'widgets.liquid-level-card.h-cylinder'], + [Shapes.hCapsule, 'widgets.liquid-level-card.h-capsule'], + [Shapes.hElliptical_2_1, 'widgets.liquid-level-card.h-elliptical_2_1'] + ] +); + +export const levelCardDefaultSettings: LevelCardWidgetSettings = { + tankSelectionType: LiquidWidgetDataSourceType.static, + selectedShape: Shapes.vCylinder, + shapeAttributeName: 'tankShape', + tankColor: constantColor('#242770'), + datasourceUnits: CapacityUnits.percent, + layout: LevelCardLayout.percentage, + showTitle: false, + title: 'Liquid level', + titleFont: { + family: 'Roboto', + size: 16, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '1.5' + }, + titleColor: 'rgba(0, 0, 0, 0.87)', + showTitleIcon: false, + titleIcon: 'water_drop', + iconColor: '#5469FF', + volumeSource: LiquidWidgetDataSourceType.static, + volumeConstant: 500, + volumeUnits: CapacityUnits.liters, + volumeAttributeName: 'volume', + volumeFont: { + family: 'Roboto', + size: 14, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '100%' + }, + volumeColor: 'rgba(0, 0, 0, 0.18)', + units: CapacityUnits.percent as string, + widgetUnitsSource: LiquidWidgetDataSourceType.static, + widgetUnitsAttributeName: 'units', + decimals: 0, + liquidColor: constantColor('#7A8BFF'), + valueFont: { + family: 'Roboto', + size: 24, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '100%' + }, + valueColor: constantColor('rgba(0, 0, 0, 0.87)'), + showBackgroundOverlay: true, + backgroundOverlayColor: constantColor('rgba(255, 255, 255, 0.76)'), + showTooltip: true, + showTooltipLevel: true, + tooltipUnits: CapacityUnits.percent, + tooltipLevelDecimals: 0, + tooltipLevelFont: { + family: 'Roboto', + size: 13, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '100%' + }, + tooltipLevelColor: constantColor('rgba(0, 0, 0, 0.76)'), + showTooltipDate: true, + tooltipDateFormat: lastUpdateAgoDateFormat(), + tooltipDateFont: { + family: 'Roboto', + size: 13, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '100%' + }, + tooltipDateColor: 'rgba(0, 0, 0, 0.76)', + tooltipBackgroundColor: 'rgba(255, 255, 255, 0.76)', + tooltipBackgroundBlur: 3, + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + } +}; + +export const convertLiters = (value: number, units: CapacityUnits, conversionType: ConversionType): number => { + let factor: number; + + switch (units) { + case CapacityUnits.liters: + return value; + case CapacityUnits.cubicMillimeter: + factor = 1e6; + break; + case CapacityUnits.cubicCentimeter: + case CapacityUnits.milliliter: + factor = 1e3; + break; + case CapacityUnits.cubicMeter: + factor = 1e-3; + break; + case CapacityUnits.cubicKilometer: + factor = 1e-15; + break; + case CapacityUnits.hectoliter: + factor = 0.01; + break; + case CapacityUnits.cubicInch: + factor = 61.0237; + break; + case CapacityUnits.cubicFoot: + factor = 1 / 28.3168; + break; + case CapacityUnits.cubicYard: + factor = 1 / 764.555; + break; + case CapacityUnits.fluidOunce: + factor = 33.814; + break; + case CapacityUnits.pint: + factor = 1 / 0.473176; + break; + case CapacityUnits.quart: + factor = 1 / 0.946353; + break; + case CapacityUnits.gallon: + factor = 1 / 3.78541; + break; + case CapacityUnits.oilBarrels: + factor = 1 / 158.987; + break; + default: + return value; + // throw new Error(`Unknown unit: ${units}`); + } + + return conversionType === ConversionType.to ? value / factor : value * factor; +}; + +export const extractValue = (attributes: Array, attributeName: string): T | undefined => { + return attributes.find(attr => attr.key === attributeName)?.value; +}; + +export const valueContainerStyleDefaults = cssTextFromInlineStyle({ + width: '100%', + height: '100%', + display: 'flex', + gap: '8px', + 'text-align': 'center', + 'align-items': 'center', + 'align-content': 'center', + 'justify-content': 'center', + 'font-family': 'Roboto, Helvetica Neue, sans-serif' +}); + +export const valueTextStyleDefaults = 'letter-spacing: normal; font-style: normal; font-weight: 500; line-height: 100%;' + + ' font-size: 24px; font-family: Roboto, Helvetica Neue, sans-serif; color: #000000DE'; + +export const volumeTextStyleDefaults = 'letter-spacing: normal; font-style: normal; font-weight: 500;' + + ' line-height: 100%; font-size: 14px; font-family: Roboto, Helvetica Neue, sans-serif; color: rgba(0, 0, 0, 0.18)'; + +export const createAbsoluteLayout = (values?: {inputValue: number | string; volume: number | string}, + styles?: {valueStyle: string; volumeStyle: string}, + units?: string): string => { + const inputValue = values?.inputValue ? values?.inputValue : 500; + const valueTextStyle = styles?.valueStyle ? styles?.valueStyle : valueTextStyleDefaults; + const volume = values?.volume ? values?.volume : 1000; + const volumeTextStyle = styles?.volumeStyle ? styles?.volumeStyle : volumeTextStyleDefaults; + const displayUnits = units ? units : CapacityUnits.liters; + + return `
+
+ +
+ +
+ +
`; +}; + +export const createPercentLayout = (value: number | string = 50, valueTextStyle: string = valueTextStyleDefaults): string => + `
+ +
`; + +export const optionsFilter = (searchText: string): ((key: DataKey) => boolean) => + key => key?.name.toUpperCase().includes(searchText?.toUpperCase()); + +export const fetchEntityKeysForDevice = (deviceId: string, dataKeyTypes: Array, + entityService: EntityService): Observable> => { + const entityFilter = singleEntityFilterFromDeviceId(deviceId); + return entityService.getEntityKeysByEntityFilter( + entityFilter, + dataKeyTypes, [EntityType.DEVICE], + {ignoreLoading: true, ignoreErrors: true} + ).pipe( + catchError(() => of([])) + ); +}; + +export const fetchEntityKeys = (entityAliasId: string, dataKeyTypes: Array, + entityService: EntityService, aliasController: IAliasController): Observable> => + aliasController.getAliasInfo(entityAliasId).pipe( + mergeMap((aliasInfo) => entityService.getEntityKeysByEntityFilter( + aliasInfo.entityFilter, + dataKeyTypes, [], + {ignoreLoading: true, ignoreErrors: true} + )), + catchError(() => of([] as Array)) + ); + + +export const createShapeLayout = (svg: string, layout: LevelCardLayout, sanitizer: DomSanitizer): SafeUrl => { + if (svg && layout) { + const parser = new DOMParser(); + const svgImage = parser.parseFromString(svg, 'image/svg+xml'); + + if (layout === LevelCardLayout.simple) { + svgImage.querySelector('.container-overlay').remove(); + } else if (layout === LevelCardLayout.percentage) { + svgImage.querySelector('.absolute-overlay').remove(); + svgImage.querySelector('.percentage-value-container').innerHTML = createPercentLayout(); + } else { + svgImage.querySelector('.absolute-value-container').innerHTML = createAbsoluteLayout(); + svgImage.querySelector('.percentage-overlay').remove(); + } + + const encodedSvg = encodeURIComponent(svgImage.documentElement.outerHTML); + + return sanitizer.bypassSecurityTrustResourceUrl(`data:image/svg+xml,${encodedSvg}`); + } +}; + +export const loadSvgShapesMapping = (resourcesService: ResourcesService): Observable> => { + const obsArray: Array> = []; + const shapesImageMap: Map = new Map(); + svgMapping.forEach((value, shape) => { + const obs = resourcesService.loadJsonResource(value.svg).pipe( + map((svg) => ({svg, shape})) + ); + + obsArray.push(obs); + }); + + return forkJoin(obsArray).pipe( + map(svgData => { + for (const data of svgData) { + shapesImageMap.set(data.shape, data.svg); + } + return shapesImageMap; + }) + ); +}; + +export const updatedFormSettingsValidators = (formGroup: FormGroup) => { + const datasourceUnits: string = formGroup.get('datasourceUnits').value; + const layout: LevelCardLayout = formGroup.get('layout').value; + const volumeSource: LiquidWidgetDataSourceType = formGroup.get('volumeSource').value; + const widgetUnitsSource: LiquidWidgetDataSourceType = formGroup.get('widgetUnitsSource').value; + const showTooltipLevel: boolean = formGroup.get('showTooltipLevel').value; + const showTooltipDate: boolean = formGroup.get('showTooltipDate').value; + const showTooltip: boolean = formGroup.get('showTooltip').value; + const tankSelectionType: LiquidWidgetDataSourceType = formGroup.get('tankSelectionType').value; + + if (tankSelectionType === LiquidWidgetDataSourceType.static) { + formGroup.get('selectedShape').enable({emitEvent: false}); + formGroup.get('shapeAttributeName').disable({emitEvent: false}); + } else { + formGroup.get('selectedShape').disable({emitEvent: false}); + formGroup.get('shapeAttributeName').enable({emitEvent: false}); + } + + switch (layout) { + case LevelCardLayout.simple: + case LevelCardLayout.percentage: + formGroup.get('widgetUnitsSource').disable({emitEvent: false}); + formGroup.get('units').disable({emitEvent: false}); + formGroup.get('widgetUnitsAttributeName').disable({emitEvent: false}); + + if (datasourceUnits !== CapacityUnits.percent) { + formGroup.get('volumeSource').enable({emitEvent: false}); + formGroup.get('volumeUnits').enable({emitEvent: false}); + if (volumeSource === LiquidWidgetDataSourceType.static) { + formGroup.get('volumeConstant').enable({emitEvent: false}); + formGroup.get('volumeAttributeName').disable({emitEvent: false}); + } else { + formGroup.get('volumeConstant').disable({emitEvent: false}); + formGroup.get('volumeAttributeName').enable({emitEvent: false}); + } + } else { + formGroup.get('volumeSource').disable({emitEvent: false}); + formGroup.get('volumeConstant').disable({emitEvent: false}); + formGroup.get('volumeAttributeName').disable({emitEvent: false}); + formGroup.get('volumeUnits').disable({emitEvent: false}); + } + + if (layout === LevelCardLayout.simple) { + formGroup.get('decimals')?.disable({emitEvent: false}); + formGroup.get('valueFont').disable({emitEvent: false}); + formGroup.get('valueColor').disable({emitEvent: false}); + } else { + formGroup.get('decimals')?.enable({emitEvent: false}); + formGroup.get('valueFont').enable({emitEvent: false}); + formGroup.get('valueColor').enable({emitEvent: false}); + } + + formGroup.get('volumeFont').disable({emitEvent: false}); + formGroup.get('volumeColor').disable({emitEvent: false}); + + break; + case LevelCardLayout.absolute: + formGroup.get('widgetUnitsSource').enable({emitEvent: false}); + if (widgetUnitsSource === LiquidWidgetDataSourceType.static) { + formGroup.get('units').enable({emitEvent: false}); + formGroup.get('widgetUnitsAttributeName').disable({emitEvent: false}); + } else { + formGroup.get('units').disable({emitEvent: false}); + formGroup.get('widgetUnitsAttributeName').enable({emitEvent: false}); + } + + formGroup.get('volumeSource').enable({emitEvent: false}); + formGroup.get('volumeUnits').enable({emitEvent: false}); + if (volumeSource === LiquidWidgetDataSourceType.static) { + formGroup.get('volumeConstant').enable({emitEvent: false}); + formGroup.get('volumeAttributeName').disable({emitEvent: false}); + } else { + formGroup.get('volumeConstant').disable({emitEvent: false}); + formGroup.get('volumeAttributeName').enable({emitEvent: false}); + } + + if (formGroup.get('decimals')) { + formGroup.get('decimals').enable({emitEvent: false}); + } + formGroup.get('valueFont').enable({emitEvent: false}); + formGroup.get('valueColor').enable({emitEvent: false}); + + formGroup.get('volumeFont').enable({emitEvent: false}); + formGroup.get('volumeColor').enable({emitEvent: false}); + + break; + } + + if (showTooltip) { + formGroup.get('showTooltipLevel').enable({emitEvent: false}); + formGroup.get('showTooltipDate').enable({emitEvent: false}); + formGroup.get('tooltipBackgroundColor').enable({emitEvent: false}); + formGroup.get('tooltipBackgroundBlur').enable({emitEvent: false}); + + if (showTooltipLevel) { + formGroup.get('tooltipLevelDecimals').enable({emitEvent: false}); + formGroup.get('tooltipLevelFont').enable({emitEvent: false}); + formGroup.get('tooltipLevelColor').enable({emitEvent: false}); + formGroup.get('tooltipUnits').enable({emitEvent: false}); + } else { + formGroup.get('tooltipUnits').disable({emitEvent: false}); + formGroup.get('tooltipLevelDecimals').disable({emitEvent: false}); + formGroup.get('tooltipLevelFont').disable({emitEvent: false}); + formGroup.get('tooltipLevelColor').disable({emitEvent: false}); + } + + if (showTooltipDate) { + formGroup.get('tooltipDateFormat').enable({emitEvent: false}); + formGroup.get('tooltipDateFont').enable({emitEvent: false}); + formGroup.get('tooltipDateColor').enable({emitEvent: false}); + } else { + formGroup.get('tooltipDateFormat').disable({emitEvent: false}); + formGroup.get('tooltipDateFont').disable({emitEvent: false}); + formGroup.get('tooltipDateColor').disable({emitEvent: false}); + } + } else { + formGroup.get('showTooltipLevel').disable({emitEvent: false}); + formGroup.get('showTooltipDate').disable({emitEvent: false}); + formGroup.get('tooltipBackgroundColor').disable({emitEvent: false}); + formGroup.get('tooltipBackgroundBlur').disable({emitEvent: false}); + + formGroup.get('tooltipUnits').disable({emitEvent: false}); + formGroup.get('tooltipLevelDecimals').disable({emitEvent: false}); + formGroup.get('tooltipLevelFont').disable({emitEvent: false}); + formGroup.get('tooltipLevelColor').disable({emitEvent: false}); + + formGroup.get('tooltipDateFormat').disable({emitEvent: false}); + formGroup.get('tooltipDateFont').disable({emitEvent: false}); + formGroup.get('tooltipDateColor').disable({emitEvent: false}); + } +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.html index a2bf74e942..79f95c52d3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.html @@ -1,6 +1,6 @@ -
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.scss index c2c14ffb76..3af5baa58b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.ts index 5891f2b297..bee975bd1f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.ts @@ -1,3 +1,19 @@ +/// +/// 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. +/// + /// /// Copyright © 2016-2023 The Thingsboard Authors @@ -51,6 +67,9 @@ import { } from '@home/components/widget/lib/indicator/signal-strength-widget.models'; import tinycolor from 'tinycolor2'; import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; const shapeWidth = 149; const shapeHeight = 113; @@ -104,7 +123,7 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi }; tooltipDateStyle: ComponentStyle = {}; - backgroundStyle: ComponentStyle = {}; + backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; shapeResize$: ResizeObserver; @@ -128,6 +147,8 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi private noData = false; constructor(public widgetComponent: WidgetComponent, + private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, private translate: TranslateService, private renderer: Renderer2, private cd: ChangeDetectorRef) { @@ -142,7 +163,7 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi this.showDate = this.settings.showDate; if (this.showDate) { this.dateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.dateFormat); - this.dateStyle = textStyle(this.settings.dateFont, '0.25px'); + this.dateStyle = textStyle(this.settings.dateFont); this.dateStyle.color = this.settings.dateColor; } @@ -173,7 +194,7 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi if (dataKey?.units) { this.units = dataKey.units; } - this.tooltipValueStyle = textStyle(this.settings.tooltipValueFont, '0.13px'); + this.tooltipValueStyle = textStyle(this.settings.tooltipValueFont); this.tooltipValueStyle.color = this.settings.tooltipValueColor; this.tooltipValueLabelStyle = {...this.tooltipValueStyle, ...this.tooltipValueLabelStyle}; } @@ -181,12 +202,12 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi if (this.showTooltipDate) { this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, {...this.settings.tooltipDateFormat, ...{hideLastUpdatePrefix: true}}); - this.tooltipDateStyle = textStyle(this.settings.tooltipDateFont, '0.13px'); + this.tooltipDateStyle = textStyle(this.settings.tooltipDateFont); this.tooltipDateStyle.color = this.settings.tooltipDateColor; this.tooltipDateLabelStyle = {...this.tooltipDateStyle, ...this.tooltipDateLabelStyle}; } - this.backgroundStyle = backgroundStyle(this.settings.background); + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0; @@ -232,7 +253,7 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi if (!this.noData) { this.rssi = Number(value); if (this.showTooltipValue) { - this.tooltipValueText = formatValue(value, this.decimals, this.units, true); + this.tooltipValueText = formatValue(value, this.decimals, this.units, false); } } else { this.rssi = -100; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.models.ts index 59c782f5d4..144d9e891b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html index 40636a9d1b..b24ad37ede 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html @@ -1,6 +1,6 @@
- - +
widgets.label-widget.labels
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.ts index 191586b683..ff38162bbe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/markdown-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/markdown-widget-settings.component.html index cf5415afe0..e33529a179 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/markdown-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/markdown-widget-settings.component.html @@ -1,6 +1,6 @@ + +
+
widgets.bar-chart.bar-chart-card-style
+
+ + {{ 'widgets.bar-chart.label-on-bar' | translate }} + +
+ + + + +
+
+
+ + {{ 'widgets.bar-chart.value-on-bar' | translate }} + +
+ + + + +
+
+
+ + + + + {{ 'widget-config.legend' | translate }} + + + + +
+
{{ 'legend.position' | translate }}
+ + + + {{ legendPositionTranslationMap.get(pos) | translate }} + + + +
+
+
{{ 'legend.label' | translate }}
+
+ + + + +
+
+
+
+
+
+ + + + + {{ 'widget-config.tooltip' | translate }} + + + + +
+
{{ 'tooltip.value' | translate }}
+
+ + + + +
+
+
+ + {{ 'tooltip.date' | translate }} + +
+ + + + + +
+
+
+
{{ 'tooltip.background-color' | translate }}
+ + +
+
+
{{ 'tooltip.background-blur' | translate }}
+ + +
px
+
+
+
+
+
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.ts new file mode 100644 index 0000000000..0d547922eb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.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 { Component, Injector } from '@angular/core'; +import { + legendPositions, + legendPositionTranslationMap, + WidgetSettings, + WidgetSettingsComponent +} from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { formatValue } from '@core/utils'; +import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models'; +import { + barChartWithLabelsDefaultSettings +} from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.models'; + +@Component({ + selector: 'tb-bar-chart-with-labels-widget-settings', + templateUrl: './bar-chart-with-labels-widget-settings.component.html', + styleUrls: [] +}) +export class BarChartWithLabelsWidgetSettingsComponent extends WidgetSettingsComponent { + + legendPositions = legendPositions; + + legendPositionTranslationMap = legendPositionTranslationMap; + + barChartWidgetSettingsForm: UntypedFormGroup; + + tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this); + + tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this); + + constructor(protected store: Store, + private $injector: Injector, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.barChartWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...barChartWithLabelsDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.barChartWidgetSettingsForm = this.fb.group({ + + showBarLabel: [settings.showBarLabel, []], + barLabelFont: [settings.barLabelFont, []], + barLabelColor: [settings.barLabelColor, []], + showBarValue: [settings.showBarValue, []], + barValueFont: [settings.barValueFont, []], + barValueColor: [settings.barValueColor, []], + + showLegend: [settings.showLegend, []], + legendPosition: [settings.legendPosition, []], + legendLabelFont: [settings.legendLabelFont, []], + legendLabelColor: [settings.legendLabelColor, []], + + showTooltip: [settings.showTooltip, []], + tooltipValueFont: [settings.tooltipValueFont, []], + tooltipValueColor: [settings.tooltipValueColor, []], + tooltipShowDate: [settings.tooltipShowDate, []], + tooltipDateFormat: [settings.tooltipDateFormat, []], + tooltipDateFont: [settings.tooltipDateFont, []], + tooltipDateColor: [settings.tooltipDateColor, []], + tooltipBackgroundColor: [settings.tooltipBackgroundColor, []], + tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []], + + background: [settings.background, []] + }); + } + + protected validatorTriggers(): string[] { + return ['showBarLabel', 'showBarValue', 'showLegend', 'showTooltip', 'tooltipShowDate']; + } + + protected updateValidators(emitEvent: boolean) { + const showBarLabel: boolean = this.barChartWidgetSettingsForm.get('showBarLabel').value; + const showBarValue: boolean = this.barChartWidgetSettingsForm.get('showBarValue').value; + const showLegend: boolean = this.barChartWidgetSettingsForm.get('showLegend').value; + const showTooltip: boolean = this.barChartWidgetSettingsForm.get('showTooltip').value; + const tooltipShowDate: boolean = this.barChartWidgetSettingsForm.get('tooltipShowDate').value; + + if (showBarLabel) { + this.barChartWidgetSettingsForm.get('barLabelFont').enable(); + this.barChartWidgetSettingsForm.get('barLabelColor').enable(); + } else { + this.barChartWidgetSettingsForm.get('barLabelFont').disable(); + this.barChartWidgetSettingsForm.get('barLabelColor').disable(); + } + + if (showBarValue) { + this.barChartWidgetSettingsForm.get('barValueFont').enable(); + this.barChartWidgetSettingsForm.get('barValueColor').enable(); + } else { + this.barChartWidgetSettingsForm.get('barValueFont').disable(); + this.barChartWidgetSettingsForm.get('barValueColor').disable(); + } + + if (showLegend) { + this.barChartWidgetSettingsForm.get('legendPosition').enable(); + this.barChartWidgetSettingsForm.get('legendLabelFont').enable(); + this.barChartWidgetSettingsForm.get('legendLabelColor').enable(); + } else { + this.barChartWidgetSettingsForm.get('legendPosition').disable(); + this.barChartWidgetSettingsForm.get('legendLabelFont').disable(); + this.barChartWidgetSettingsForm.get('legendLabelColor').disable(); + } + + if (showTooltip) { + this.barChartWidgetSettingsForm.get('tooltipValueFont').enable(); + this.barChartWidgetSettingsForm.get('tooltipValueColor').enable(); + this.barChartWidgetSettingsForm.get('tooltipShowDate').enable({emitEvent: false}); + this.barChartWidgetSettingsForm.get('tooltipBackgroundColor').enable(); + this.barChartWidgetSettingsForm.get('tooltipBackgroundBlur').enable(); + if (tooltipShowDate) { + this.barChartWidgetSettingsForm.get('tooltipDateFormat').enable(); + this.barChartWidgetSettingsForm.get('tooltipDateFont').enable(); + this.barChartWidgetSettingsForm.get('tooltipDateColor').enable(); + } else { + this.barChartWidgetSettingsForm.get('tooltipDateFormat').disable(); + this.barChartWidgetSettingsForm.get('tooltipDateFont').disable(); + this.barChartWidgetSettingsForm.get('tooltipDateColor').disable(); + } + } else { + this.barChartWidgetSettingsForm.get('tooltipValueFont').disable(); + this.barChartWidgetSettingsForm.get('tooltipValueColor').disable(); + this.barChartWidgetSettingsForm.get('tooltipShowDate').disable({emitEvent: false}); + this.barChartWidgetSettingsForm.get('tooltipDateFormat').disable(); + this.barChartWidgetSettingsForm.get('tooltipDateFont').disable(); + this.barChartWidgetSettingsForm.get('tooltipDateColor').disable(); + this.barChartWidgetSettingsForm.get('tooltipBackgroundColor').disable(); + this.barChartWidgetSettingsForm.get('tooltipBackgroundBlur').disable(); + } + } + + private _tooltipValuePreviewFn(): string { + const units: string = this.widgetConfig.config.units; + const decimals: number = this.widgetConfig.config.decimals; + return formatValue(22, decimals, units, false); + } + + private _tooltipDatePreviewFn(): string { + const dateFormat: DateFormatSettings = this.barChartWidgetSettingsForm.get('tooltipDateFormat').value; + const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat); + processor.update(Date.now()); + return processor.formatted; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.html index d30182f73d..3922858711 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.html @@ -1,6 +1,6 @@ + +
+
widgets.doughnut.doughnut-card-style
+ + + {{ doughnutLayoutTranslationMap.get(layout) | translate }} + + +
+ + {{ 'widgets.doughnut.auto-scale' | translate }} + +
+
+ + {{ 'widgets.doughnut.clockwise-layout' | translate }} + +
+
+ + {{ 'widgets.doughnut.sort-series' | translate }} + +
+
+
{{ 'widgets.doughnut.central-total-value' | translate }}
+
+ + + + +
+
+
+ + + + + {{ 'widget-config.legend' | translate }} + + + + +
+
{{ 'legend.position' | translate }}
+ + + + {{ legendPositionTranslationMap.get(pos) | translate }} + + + +
+
+
{{ 'legend.label' | translate }}
+
+ + + + +
+
+
+
{{ 'legend.value' | translate }}
+
+ + + + +
+
+
+
+
+
+ + + + + {{ 'widget-config.tooltip' | translate }} + + + + +
+
{{ 'tooltip.value' | translate }}
+
+ + + + {{ doughnutTooltipValueTypeTranslationMap.get(type) | translate }} + + + + + +
widget-config.decimals-suffix
+
+ + + + +
+
+
+
{{ 'tooltip.background-color' | translate }}
+ + +
+
+
{{ 'tooltip.background-blur' | translate }}
+ + +
px
+
+
+
+
+
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts new file mode 100644 index 0000000000..20910e36af --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts @@ -0,0 +1,188 @@ +/// +/// 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 { + legendPositions, + legendPositionTranslationMap, + WidgetSettings, + WidgetSettingsComponent +} from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { formatValue, isDefinedAndNotNull } from '@core/utils'; +import { + doughnutDefaultSettings, + DoughnutLayout, + doughnutLayoutImages, + doughnutLayouts, + doughnutLayoutTranslations, + DoughnutTooltipValueType, + doughnutTooltipValueTypes, + doughnutTooltipValueTypeTranslations, + horizontalDoughnutLayoutImages +} from '@home/components/widget/lib/chart/doughnut-widget.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; + +@Component({ + selector: 'tb-doughnut-widget-settings', + templateUrl: './doughnut-widget-settings.component.html', + styleUrls: [] +}) +export class DoughnutWidgetSettingsComponent extends WidgetSettingsComponent { + + get totalEnabled(): boolean { + const layout: DoughnutLayout = this.doughnutWidgetSettingsForm.get('layout').value; + return layout === DoughnutLayout.with_total; + } + + doughnutLayouts = doughnutLayouts; + + doughnutLayoutTranslationMap = doughnutLayoutTranslations; + + horizontal = false; + + doughnutLayoutImageMap: Map; + + legendPositions = legendPositions; + + legendPositionTranslationMap = legendPositionTranslationMap; + + doughnutTooltipValueTypes = doughnutTooltipValueTypes; + + doughnutTooltipValueTypeTranslationMap = doughnutTooltipValueTypeTranslations; + + doughnutWidgetSettingsForm: UntypedFormGroup; + + valuePreviewFn = this._valuePreviewFn.bind(this); + + tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this); + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.doughnutWidgetSettingsForm; + } + + protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) { + const params = widgetConfig.typeParameters as any; + this.horizontal = isDefinedAndNotNull(params.horizontal) ? params.horizontal : false; + this.doughnutLayoutImageMap = this.horizontal ? horizontalDoughnutLayoutImages : doughnutLayoutImages; + } + + protected defaultSettings(): WidgetSettings { + return doughnutDefaultSettings(this.horizontal); + } + + protected onSettingsSet(settings: WidgetSettings) { + this.doughnutWidgetSettingsForm = this.fb.group({ + layout: [settings.layout, []], + autoScale: [settings.autoScale, []], + clockwise: [settings.clockwise, []], + sortSeries: [settings.sortSeries, []], + + totalValueFont: [settings.totalValueFont, []], + totalValueColor: [settings.totalValueColor, []], + + showLegend: [settings.showLegend, []], + legendPosition: [settings.legendPosition, []], + legendLabelFont: [settings.legendLabelFont, []], + legendLabelColor: [settings.legendLabelColor, []], + legendValueFont: [settings.legendValueFont, []], + legendValueColor: [settings.legendValueColor, []], + + showTooltip: [settings.showTooltip, []], + tooltipValueType: [settings.tooltipValueType, []], + tooltipValueDecimals: [settings.tooltipValueDecimals, []], + tooltipValueFont: [settings.tooltipValueFont, []], + tooltipValueColor: [settings.tooltipValueColor, []], + tooltipBackgroundColor: [settings.tooltipBackgroundColor, []], + tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []], + + background: [settings.background, []] + }); + } + + protected validatorTriggers(): string[] { + return ['layout', 'showLegend', 'showTooltip']; + } + + protected updateValidators(emitEvent: boolean) { + const layout: DoughnutLayout = this.doughnutWidgetSettingsForm.get('layout').value; + const showLegend: boolean = this.doughnutWidgetSettingsForm.get('showLegend').value; + const showTooltip: boolean = this.doughnutWidgetSettingsForm.get('showTooltip').value; + + const totalEnabled = layout === DoughnutLayout.with_total; + + if (showLegend) { + this.doughnutWidgetSettingsForm.get('legendPosition').enable(); + this.doughnutWidgetSettingsForm.get('legendLabelFont').enable(); + this.doughnutWidgetSettingsForm.get('legendLabelColor').enable(); + this.doughnutWidgetSettingsForm.get('legendValueFont').enable(); + this.doughnutWidgetSettingsForm.get('legendValueColor').enable(); + } else { + this.doughnutWidgetSettingsForm.get('legendPosition').disable(); + this.doughnutWidgetSettingsForm.get('legendLabelFont').disable(); + this.doughnutWidgetSettingsForm.get('legendLabelColor').disable(); + this.doughnutWidgetSettingsForm.get('legendValueFont').disable(); + this.doughnutWidgetSettingsForm.get('legendValueColor').disable(); + } + if (showTooltip) { + this.doughnutWidgetSettingsForm.get('tooltipValueType').enable(); + this.doughnutWidgetSettingsForm.get('tooltipValueDecimals').enable(); + this.doughnutWidgetSettingsForm.get('tooltipValueFont').enable(); + this.doughnutWidgetSettingsForm.get('tooltipValueColor').enable(); + this.doughnutWidgetSettingsForm.get('tooltipBackgroundColor').enable(); + this.doughnutWidgetSettingsForm.get('tooltipBackgroundBlur').enable(); + } else { + this.doughnutWidgetSettingsForm.get('tooltipValueType').disable(); + this.doughnutWidgetSettingsForm.get('tooltipValueDecimals').disable(); + this.doughnutWidgetSettingsForm.get('tooltipValueFont').disable(); + this.doughnutWidgetSettingsForm.get('tooltipValueColor').disable(); + this.doughnutWidgetSettingsForm.get('tooltipBackgroundColor').disable(); + this.doughnutWidgetSettingsForm.get('tooltipBackgroundBlur').disable(); + } + if (totalEnabled) { + this.doughnutWidgetSettingsForm.get('totalValueFont').enable(); + this.doughnutWidgetSettingsForm.get('totalValueColor').enable(); + } else { + this.doughnutWidgetSettingsForm.get('totalValueFont').disable(); + this.doughnutWidgetSettingsForm.get('totalValueColor').disable(); + } + } + + private _valuePreviewFn(): string { + const units: string = this.widgetConfig.config.units; + const decimals: number = this.widgetConfig.config.decimals; + return formatValue(110, decimals, units, false); + } + + private _tooltipValuePreviewFn(): string { + const tooltipValueType: DoughnutTooltipValueType = this.doughnutWidgetSettingsForm.get('tooltipValueType').value; + const decimals: number = this.doughnutWidgetSettingsForm.get('tooltipValueDecimals').value; + if (tooltipValueType === DoughnutTooltipValueType.percentage) { + return formatValue(35, decimals, '%', false); + } else { + const units: string = this.widgetConfig.config.units; + return formatValue(110, decimals, units, false); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.html index d84e08ef91..34476180f4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.html @@ -1,6 +1,6 @@ + +
+
widgets.range-chart.range-chart-card-style
+
+ + {{ 'widgets.range-chart.data-zoom' | translate }} + +
+
+
{{ 'widgets.range-chart.range-colors' | translate }}
+ + +
+
+
{{ 'widgets.range-chart.out-of-range-color' | translate }}
+ + +
+
+ + {{ 'widgets.range-chart.fill-area' | translate }} + +
+
+ + + + + {{ 'widget-config.legend' | translate }} + + + + +
+
{{ 'legend.position' | translate }}
+ + + + {{ legendPositionTranslationMap.get(pos) | translate }} + + + +
+
+
{{ 'legend.label' | translate }}
+
+ + + + +
+
+
+
+
+
+ + + + + {{ 'widget-config.tooltip' | translate }} + + + + +
+
{{ 'tooltip.value' | translate }}
+
+ + + + +
+
+
+ + {{ 'tooltip.date' | translate }} + +
+ + + + + +
+
+
+
{{ 'tooltip.background-color' | translate }}
+ + +
+
+
{{ 'tooltip.background-blur' | translate }}
+ + +
px
+
+
+
+
+
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts new file mode 100644 index 0000000000..c0e043995d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts @@ -0,0 +1,147 @@ +/// +/// 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, Injector } from '@angular/core'; +import { + legendPositions, + legendPositionTranslationMap, + WidgetSettings, + WidgetSettingsComponent +} from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { formatValue } from '@core/utils'; +import { rangeChartDefaultSettings } from '@home/components/widget/lib/chart/range-chart-widget.models'; +import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models'; + +@Component({ + selector: 'tb-range-chart-widget-settings', + templateUrl: './range-chart-widget-settings.component.html', + styleUrls: [] +}) +export class RangeChartWidgetSettingsComponent extends WidgetSettingsComponent { + + legendPositions = legendPositions; + + legendPositionTranslationMap = legendPositionTranslationMap; + + rangeChartWidgetSettingsForm: UntypedFormGroup; + + tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this); + + tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this); + + constructor(protected store: Store, + private $injector: Injector, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.rangeChartWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...rangeChartDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.rangeChartWidgetSettingsForm = this.fb.group({ + dataZoom: [settings.dataZoom, []], + rangeColors: [settings.rangeColors, []], + outOfRangeColor: [settings.outOfRangeColor, []], + fillArea: [settings.fillArea, []], + + showLegend: [settings.showLegend, []], + legendPosition: [settings.legendPosition, []], + legendLabelFont: [settings.legendLabelFont, []], + legendLabelColor: [settings.legendLabelColor, []], + + showTooltip: [settings.showTooltip, []], + tooltipValueFont: [settings.tooltipValueFont, []], + tooltipValueColor: [settings.tooltipValueColor, []], + tooltipShowDate: [settings.tooltipShowDate, []], + tooltipDateFormat: [settings.tooltipDateFormat, []], + tooltipDateFont: [settings.tooltipDateFont, []], + tooltipDateColor: [settings.tooltipDateColor, []], + tooltipBackgroundColor: [settings.tooltipBackgroundColor, []], + tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []], + + background: [settings.background, []] + }); + } + + protected validatorTriggers(): string[] { + return ['showLegend', 'showTooltip', 'tooltipShowDate']; + } + + protected updateValidators(emitEvent: boolean) { + const showLegend: boolean = this.rangeChartWidgetSettingsForm.get('showLegend').value; + const showTooltip: boolean = this.rangeChartWidgetSettingsForm.get('showTooltip').value; + const tooltipShowDate: boolean = this.rangeChartWidgetSettingsForm.get('tooltipShowDate').value; + + if (showLegend) { + this.rangeChartWidgetSettingsForm.get('legendPosition').enable(); + this.rangeChartWidgetSettingsForm.get('legendLabelFont').enable(); + this.rangeChartWidgetSettingsForm.get('legendLabelColor').enable(); + } else { + this.rangeChartWidgetSettingsForm.get('legendPosition').disable(); + this.rangeChartWidgetSettingsForm.get('legendLabelFont').disable(); + this.rangeChartWidgetSettingsForm.get('legendLabelColor').disable(); + } + + if (showTooltip) { + this.rangeChartWidgetSettingsForm.get('tooltipValueFont').enable(); + this.rangeChartWidgetSettingsForm.get('tooltipValueColor').enable(); + this.rangeChartWidgetSettingsForm.get('tooltipShowDate').enable({emitEvent: false}); + this.rangeChartWidgetSettingsForm.get('tooltipBackgroundColor').enable(); + this.rangeChartWidgetSettingsForm.get('tooltipBackgroundBlur').enable(); + if (tooltipShowDate) { + this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').enable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateFont').enable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateColor').enable(); + } else { + this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateFont').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateColor').disable(); + } + } else { + this.rangeChartWidgetSettingsForm.get('tooltipValueFont').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipValueColor').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipShowDate').disable({emitEvent: false}); + this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateFont').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateColor').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipBackgroundColor').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipBackgroundBlur').disable(); + } + } + + private _tooltipValuePreviewFn(): string { + const units: string = this.widgetConfig.config.units; + const decimals: number = this.widgetConfig.config.decimals; + return formatValue(22, decimals, units, false); + } + + private _tooltipDatePreviewFn(): string { + const dateFormat: DateFormatSettings = this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').value; + const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat); + processor.update(Date.now()); + return processor.formatted; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html index 42c4472cca..6cca169f5a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html @@ -1,6 +1,6 @@
widgets.background.background-settings
-
+
widgets.background.background
@@ -27,14 +27,8 @@
- -
-
widgets.background.image-url
- - - -
-
+ +
widgets.color.color
@@ -64,7 +58,7 @@
widgets.background.preview
-
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.scss index 258117512a..01297b9d12 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - @import '../../../../../../../../scss/constants'; .tb-background-settings-panel { @@ -31,6 +30,12 @@ letter-spacing: 0.25px; color: rgba(0, 0, 0, 0.87); } + .tb-background-form-panel { + height: 192px; + .tb-background-color-field { + height: auto; + } + } .tb-background-settings-preview { flex: 1; background: rgba(0, 0, 0, 0.04); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts index ab94b95800..c1c85a05e1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { backgroundStyle, @@ -27,6 +27,9 @@ import { TbPopoverComponent } from '@shared/components/popover.component'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { Observable } from 'rxjs'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; @Component({ selector: 'tb-background-settings-panel', @@ -54,11 +57,14 @@ export class BackgroundSettingsPanelComponent extends PageComponent implements O backgroundSettingsFormGroup: UntypedFormGroup; - backgroundStyle: ComponentStyle = {}; + backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; constructor(private fb: UntypedFormBuilder, - protected store: Store) { + private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, + protected store: Store, + private cd: ChangeDetectorRef) { super(store); } @@ -66,7 +72,6 @@ export class BackgroundSettingsPanelComponent extends PageComponent implements O this.backgroundSettingsFormGroup = this.fb.group( { type: [this.backgroundSettings?.type, []], - imageBase64: [this.backgroundSettings?.imageBase64, []], imageUrl: [this.backgroundSettings?.imageUrl, []], color: [this.backgroundSettings?.color, []], overlay: this.fb.group({ @@ -101,11 +106,11 @@ export class BackgroundSettingsPanelComponent extends PageComponent implements O private updateValidators() { const overlayEnabled: boolean = this.backgroundSettingsFormGroup.get('overlay').get('enabled').value; if (overlayEnabled) { - this.backgroundSettingsFormGroup.get('overlay').get('color').enable(); - this.backgroundSettingsFormGroup.get('overlay').get('blur').enable(); + this.backgroundSettingsFormGroup.get('overlay').get('color').enable({emitEvent: false}); + this.backgroundSettingsFormGroup.get('overlay').get('blur').enable({emitEvent: false}); } else { - this.backgroundSettingsFormGroup.get('overlay').get('color').disable(); - this.backgroundSettingsFormGroup.get('overlay').get('blur').disable(); + this.backgroundSettingsFormGroup.get('overlay').get('color').disable({emitEvent: false}); + this.backgroundSettingsFormGroup.get('overlay').get('blur').disable({emitEvent: false}); } this.backgroundSettingsFormGroup.get('overlay').get('color').updateValueAndValidity({emitEvent: false}); this.backgroundSettingsFormGroup.get('overlay').get('blur').updateValueAndValidity({emitEvent: false}); @@ -113,8 +118,9 @@ export class BackgroundSettingsPanelComponent extends PageComponent implements O private updateBackgroundStyle() { const background: BackgroundSettings = this.backgroundSettingsFormGroup.value; - this.backgroundStyle = backgroundStyle(background); + this.backgroundStyle$ = backgroundStyle(background, this.imagePipe, this.sanitizer, true); this.overlayStyle = overlayStyle(background.overlay); + this.cd.markForCheck(); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.html index e9e1b99b0e..ea685bd85f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.html @@ -1,6 +1,6 @@ +
{{ panelTitle }}
+
+
+
+
+
widgets.color.from
+ + + +
widgets.color.to
+ + + + + +
+ +
+
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts new file mode 100644 index 0000000000..287ab13ce1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.ts @@ -0,0 +1,136 @@ +/// +/// 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, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormGroup, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup +} from '@angular/forms'; +import { ColorRange } from '@shared/models/widget-settings.models'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'tb-color-range-list', + templateUrl: './color-range-list.component.html', + styleUrls: ['color-settings-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ColorRangeListComponent), + multi: true + } + ] +}) +export class ColorRangeListComponent implements OnInit, ControlValueAccessor, OnDestroy { + + @Input() + disabled: boolean; + + @Input() + popover: TbPopoverComponent; + + @Input() + panelTitle: string; + + modelValue: any; + + colorRangeListFormGroup: UntypedFormGroup; + + private destroy$ = new Subject(); + + private propagateChange = null; + + constructor(private fb: UntypedFormBuilder) {} + + ngOnInit(): void { + this.colorRangeListFormGroup = this.fb.group({ + rangeList: this.fb.array([]) + }); + + this.colorRangeListFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateModel()); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: any): void { + if (value && value?.length) { + value.forEach((r) => this.rangeListFormArray.push(this.colorRangeControl(r), {emitEvent: false})); + } + } + + private colorRangeControl(range: ColorRange): UntypedFormGroup { + return this.fb.group({ + from: [range?.from, []], + to: [range?.to, []], + color: [range?.color, []] + }); + } + + get rangeListFormArray(): UntypedFormArray { + return this.colorRangeListFormGroup.get('rangeList') as UntypedFormArray; + } + + get rangeListFormGroups(): FormGroup[] { + return this.rangeListFormArray.controls as FormGroup[]; + } + + trackByRange(index: number, rangeControl: AbstractControl): any { + return rangeControl; + } + + removeRange(index: number) { + this.rangeListFormArray.removeAt(index); + this.colorRangeListFormGroup.markAsDirty(); + setTimeout(() => {this.popover?.updatePosition();}, 0); + } + + addRange() { + const newRange: ColorRange = { + color: 'rgba(0,0,0,0.87)' + }; + this.rangeListFormArray.push(this.colorRangeControl(newRange)); + this.colorRangeListFormGroup.markAsDirty(); + setTimeout(() => {this.popover?.updatePosition();}, 0); + } + + updateModel() { + this.propagateChange(this.colorRangeListFormGroup.get('rangeList').value); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.html new file mode 100644 index 0000000000..1b9a44286b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.html @@ -0,0 +1,57 @@ + +
+
widgets.color.color-settings
+
+ + +
+
+ + + +
+ +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts new file mode 100644 index 0000000000..b4baf12dad --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts @@ -0,0 +1,78 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { ColorRange } from '@shared/models/widget-settings.models'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { deepClone } from '@core/utils'; +import { + ColorRangeSettingsComponent +} from '@home/components/widget/lib/settings/common/color-range-settings.component'; + +@Component({ + selector: 'tb-color-range-panel', + templateUrl: './color-range-panel.component.html', + providers: [], + styleUrls: ['./color-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ColorRangePanelComponent extends PageComponent implements OnInit { + + @Input() + colorRangeSettings: Array; + + @Input() + popover: TbPopoverComponent; + + @Input() + settingsComponents: ColorRangeSettingsComponent[]; + + @Output() + colorRangeApplied = new EventEmitter>(); + + colorRangeFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.colorRangeFormGroup = this.fb.group({ + rangeList: [this.colorRangeSettings, []] + }); + } + + copyColorSettings(comp: ColorRangeSettingsComponent) { + this.colorRangeSettings = deepClone(comp.modelValue); + this.colorRangeFormGroup.get('rangeList').patchValue(this.colorRangeSettings || [], {emitEvent: false}); + this.colorRangeFormGroup.markAsDirty(); + } + + cancel() { + this.popover?.hide(); + } + + applyColorRangeSettings() { + const colorRangeSettings = this.colorRangeFormGroup.get('rangeList').value; + this.colorRangeApplied.emit(colorRangeSettings); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.html new file mode 100644 index 0000000000..7bf97390f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.html @@ -0,0 +1,27 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts new file mode 100644 index 0000000000..5770f45c99 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts @@ -0,0 +1,168 @@ +/// +/// 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, + forwardRef, + Injectable, + Input, + OnDestroy, + OnInit, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ColorRange, ComponentStyle } from '@shared/models/widget-settings.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { ColorRangePanelComponent } from '@home/components/widget/lib/settings/common/color-range-panel.component'; + +@Injectable() +export class ColorRangeSettingsComponentService { + + private colorSettingsComponents = new Set(); + + constructor() {} + + public registerColorSettingsComponent(comp: ColorRangeSettingsComponent) { + this.colorSettingsComponents.add(comp); + } + + public unregisterColorSettingsComponent(comp: ColorRangeSettingsComponent) { + this.colorSettingsComponents.delete(comp); + } + + public getOtherColorSettingsComponents(comp: ColorRangeSettingsComponent): ColorRangeSettingsComponent[] { + const result: ColorRangeSettingsComponent[] = []; + for (const component of this.colorSettingsComponents.values()) { + if (component.settingsKey && component.modelValue && component !== comp) { + result.push(component); + } + } + return result; + } + +} + +@Component({ + selector: 'tb-color-range-settings', + templateUrl: './color-range-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ColorRangeSettingsComponent), + multi: true + } + ] +}) +export class ColorRangeSettingsComponent implements OnInit, ControlValueAccessor, OnDestroy { + + @Input() + disabled: boolean; + + @Input() + settingsKey: string; + + modelValue: Array; + + colorStyle: ComponentStyle = {}; + + private propagateChange = null; + + constructor(private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private colorSettingsComponentService: ColorRangeSettingsComponentService) {} + + ngOnInit(): void { + this.colorSettingsComponentService.registerColorSettingsComponent(this); + } + + ngOnDestroy() { + this.colorSettingsComponentService.unregisterColorSettingsComponent(this); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.updateColorStyle(); + } + + writeValue(value: Array): void { + this.modelValue = value; + this.updateColorStyle(); + } + + openColorRangeSettingsPopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + colorRangeSettings: this.modelValue, + settingsComponents: this.colorSettingsComponentService.getOtherColorSettingsComponents(this) + }; + const colorRangeSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ColorRangePanelComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + colorRangeSettingsPanelPopover.tbComponentRef.instance.popover = colorRangeSettingsPanelPopover; + colorRangeSettingsPanelPopover.tbComponentRef.instance.colorRangeApplied.subscribe((colorRangeSettings) => { + colorRangeSettingsPanelPopover.hide(); + this.modelValue = colorRangeSettings; + this.updateColorStyle(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateColorStyle() { + if (!this.disabled && this.modelValue) { + let colors: string[] = []; + if (this.modelValue.length) { + const rangeColors = this.modelValue.slice(0, Math.min(3, this.modelValue.length)).map(r => r.color); + colors = colors.concat(rangeColors); + } + if (!colors.length) { + this.colorStyle = {}; + } else if (colors.length === 1) { + this.colorStyle = {backgroundColor: colors[0]}; + } else { + const gradientValues: string[] = []; + const step = 100 / colors.length; + for (let i = 0; i < colors.length; i++) { + gradientValues.push(`${colors[i]} ${step*i}%`); + gradientValues.push(`${colors[i]} ${step*(i+1)}%`); + } + this.colorStyle = {background: `linear-gradient(90deg, ${gradientValues.join(', ')})`}; + } + } else { + this.colorStyle = {}; + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html index 8712322d2c..b85a96325e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html @@ -1,6 +1,6 @@ - !format.lastUpdateAgo) : dateFormats; this.dateFormatFormControl = new UntypedFormControl(); this.dateFormatFormControl.valueChanges.subscribe((value: DateFormatSettings) => { this.updateModel(value); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-settings-panel.component.html index ee332924f0..2c7801f149 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-settings-panel.component.html @@ -1,6 +1,6 @@ -
-
- widgets.gauge.ticks-settings - - widgets.gauge.major-ticks-names - - - {{tickName}} - cancel - - - - - - -
- - widgets.gauge.minor-ticks-count - - - - -
- - {{ 'widgets.gauge.show-stroke-ticks' | translate }} - -
- widgets.gauge.major-ticks-font - -
-
-
- widgets.gauge.plate-settings - - - - {{ 'widgets.gauge.show-plate-border' | translate }} - -
- + +
+
widgets.gauge.ticks-settings
+
+
widgets.gauge.labels
+
+ + +
+
+
+
widgets.gauge.label-style
+
+ + + + +
+
+ +
+
widgets.gauge.major-ticks
+ - - widgets.gauge.border-width - - -
-
-
- widgets.gauge.needle-settings - - widgets.gauge.needle-circle-size - - -
- +
+
+
widgets.gauge.minor-ticks
+
+ + + + + +
+
+ +
+ + {{ 'widgets.gauge.show-stroke-ticks' | translate }} + +
+
+ +
+
widgets.gauge.plate-settings
+
+
widgets.gauge.plate-color
+ - +
+
+ + {{ 'widgets.gauge.show-plate-border' | translate }} + +
+ + + + + +
+
+
+ +
+
widgets.gauge.needle-settings
+
+
widgets.gauge.needle-circle
+
+ + + + + +
+
+
+
widgets.gauge.needle-color
+ -
- -
- widgets.gauge.animation-settings +
+
+ +
- {{ 'widgets.gauge.enable-animation' | translate }} - + widget-config.advanced-settings -
- - widgets.gauge.animation-duration - - - - widgets.gauge.animation-rule - - - {{ 'widgets.gauge.animation-linear' | translate }} - - - {{ 'widgets.gauge.animation-quad' | translate }} - - - {{ 'widgets.gauge.animation-quint' | translate }} - - - {{ 'widgets.gauge.animation-cycle' | translate }} - - - {{ 'widgets.gauge.animation-bounce' | translate }} - - - {{ 'widgets.gauge.animation-elastic' | translate }} - - - {{ 'widgets.gauge.animation-dequad' | translate }} - - - {{ 'widgets.gauge.animation-dequint' | translate }} - - - {{ 'widgets.gauge.animation-decycle' | translate }} - - - {{ 'widgets.gauge.animation-debounce' | translate }} - - - {{ 'widgets.gauge.animation-delastic' | translate }} - - - - - widgets.gauge.animation-target +
+
widgets.gauge.animation-target
+ {{ 'widgets.gauge.animation-target-needle' | translate }} @@ -171,8 +145,53 @@ -
+
+
+
widgets.gauge.animation-duration-rule
+
+ + + + + + + {{ 'widgets.gauge.animation-linear' | translate }} + + + {{ 'widgets.gauge.animation-quad' | translate }} + + + {{ 'widgets.gauge.animation-quint' | translate }} + + + {{ 'widgets.gauge.animation-cycle' | translate }} + + + {{ 'widgets.gauge.animation-bounce' | translate }} + + + {{ 'widgets.gauge.animation-elastic' | translate }} + + + {{ 'widgets.gauge.animation-dequad' | translate }} + + + {{ 'widgets.gauge.animation-dequint' | translate }} + + + {{ 'widgets.gauge.animation-decycle' | translate }} + + + {{ 'widgets.gauge.animation-debounce' | translate }} + + + {{ 'widgets.gauge.animation-delastic' | translate }} + + + +
+
- - +
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-compass-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-compass-widget-settings.component.ts index 5a2a2b2678..a62424f34c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-compass-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-compass-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -19,9 +19,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Component } from '@angular/core'; -import { MatChipInputEvent } from '@angular/material/chips'; import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; -import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; @Component({ selector: 'tb-analogue-compass-widget-settings', @@ -81,6 +79,7 @@ export class AnalogueCompassWidgetSettingsComponent extends WidgetSettingsCompon colorMinorTicks: [settings.colorMinorTicks, []], showStrokeTicks: [settings.showStrokeTicks, []], majorTickFont: [settings.majorTickFont, []], + majorTickColor: [settings.majorTickFont.color, []], // Plate settings colorPlate: [settings.colorPlate, []], @@ -131,41 +130,8 @@ export class AnalogueCompassWidgetSettingsComponent extends WidgetSettingsCompon this.analogueCompassWidgetSettingsForm.get('animationTarget').updateValueAndValidity({emitEvent}); } - majorTicksNamesList(): string[] { - return this.analogueCompassWidgetSettingsForm.get('majorTicks').value; - } - - removeMajorTickName(tickName: string): void { - const tickNames: string[] = this.analogueCompassWidgetSettingsForm.get('majorTicks').value; - const index = tickNames.indexOf(tickName); - if (index >= 0) { - tickNames.splice(index, 1); - this.analogueCompassWidgetSettingsForm.get('majorTicks').setValue(tickNames); - this.analogueCompassWidgetSettingsForm.get('majorTicks').markAsDirty(); - } - } - - addMajorTickName(event: MatChipInputEvent): void { - const input = event.chipInput.inputElement; - const value = event.value; - - const tickNames: string[] = this.analogueCompassWidgetSettingsForm.get('majorTicks').value; - - if ((value || '').trim()) { - tickNames.push(value.trim()); - this.analogueCompassWidgetSettingsForm.get('majorTicks').setValue(tickNames); - this.analogueCompassWidgetSettingsForm.get('majorTicks').markAsDirty(); - } - - if (input) { - input.value = ''; - } - } - - majorTickNameDrop(event: CdkDragDrop): void { - const tickNames: string[] = this.analogueCompassWidgetSettingsForm.get('majorTicks').value; - moveItemInArray(tickNames, event.previousIndex, event.currentIndex); - this.analogueCompassWidgetSettingsForm.get('majorTicks').setValue(tickNames); - this.analogueCompassWidgetSettingsForm.get('majorTicks').markAsDirty(); + protected prepareOutputSettings(settings) { + settings.majorTickFont.color = this.analogueCompassWidgetSettingsForm.get('majorTickColor').value; + return settings; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.html index c2264d7aca..61612be854 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.html @@ -1,6 +1,6 @@ -
+ @@ -24,330 +24,382 @@ - - -
- widgets.gauge.ticks-settings -
- - widgets.gauge.min-value - - - - widgets.gauge.max-value - - -
-
- - widgets.gauge.major-ticks-count - - - - -
-
- - widgets.gauge.minor-ticks-count - - - - -
-
- widgets.gauge.tick-numbers-font - -
-
-
- widgets.gauge.unit-title-settings - - - - - {{ 'widgets.gauge.show-unit-title' | translate }} - - - - widget-config.advanced-settings - - - -
- - widgets.gauge.unit-title - - -
- widgets.gauge.title-font - -
-
-
-
-
-
- widgets.gauge.units-settings -
- widgets.gauge.units-font - -
-
-
- widgets.gauge.value-box-settings + +
+
widgets.gauge.ticks-settings
+
+
widgets.gauge.ticks
+
+
widgets.gauge.min-value-short
+ + + +
widgets.gauge.max-value-short
+ + + + + + + +
+
+ +
+
widgets.gauge.major-ticks-count
+
+ + + + + +
+
+ +
+
widgets.gauge.minor-ticks-count
+
+ + + + + +
+
+
+ +
+
widgets.gauge.units-settings
+
+ + {{ 'widgets.gauge.units-title' | translate }} + +
+ + + + + + + +
+
+ +
+
widget-config.units-short
+
+ + + + +
+
+
+ +
- - {{ 'widgets.gauge.show-value-box' | translate }} + {{ 'widgets.gauge.value-box' | translate }} - + widget-config.advanced-settings -
- - widgets.gauge.value-int - +
+
widgets.gauge.value-int
+ + -
- widgets.gauge.value-font - -
-
- +
+
+
widgets.gauge.value-text
+
+
widgets.gauge.value-text
+
+ + + + +
+
+
+
widgets.gauge.value-text-shadow
+ - +
+
+
+
widgets.gauge.value-box
+
+
widgets.gauge.rect-stroke-color-start
+ + +
+ +
+
widgets.gauge.rect-stroke-color-end
+ -
-
- +
+ +
+
widgets.gauge.background-color
+ - +
+ +
+
widgets.gauge.shadow-color
+ -
- +
+
- -
- widgets.gauge.plate-settings - - - - {{ 'widgets.gauge.show-plate-border' | translate }} - -
-
- widgets.gauge.needle-settings -
- +
+ +
+
widgets.gauge.plate-settings
+
+
widgets.gauge.plate-color
+ - +
+
+ + {{ 'widgets.gauge.show-plate-border' | translate }} + +
+
+ +
+
widgets.gauge.needle-settings
+
+
widgets.gauge.needle-color-start
+ - -
- +
+ +
+
widgets.gauge.needle-color-end
+ - +
+ +
+
widgets.gauge.needle-color-shadow-up
+ - - -
- widgets.gauge.highlights-settings - - widgets.gauge.highlights-width - - -
- widgets.gauge.highlights -
-
-
- - -
-
-
- widgets.gauge.no-highlights -
-
- -
+
+ +
+
widgets.gauge.needle-color-shadow-down
+ + +
+
+ +
+
widgets.gauge.scale-settings
+
+
widgets.gauge.scale
+
+ + + + +
- - -
- widgets.gauge.animation-settings +
+
+ +
- {{ 'widgets.gauge.enable-animation' | translate }} - + widget-config.advanced-settings -
- - widgets.gauge.animation-duration - - - - widgets.gauge.animation-rule - - - {{ 'widgets.gauge.animation-linear' | translate }} - - - {{ 'widgets.gauge.animation-quad' | translate }} - - - {{ 'widgets.gauge.animation-quint' | translate }} - - - {{ 'widgets.gauge.animation-cycle' | translate }} - - - {{ 'widgets.gauge.animation-bounce' | translate }} - - - {{ 'widgets.gauge.animation-elastic' | translate }} - - - {{ 'widgets.gauge.animation-dequad' | translate }} - - - {{ 'widgets.gauge.animation-dequint' | translate }} - - - {{ 'widgets.gauge.animation-decycle' | translate }} - - - {{ 'widgets.gauge.animation-debounce' | translate }} - - - {{ 'widgets.gauge.animation-delastic' | translate }} - - - -
+
+
widgets.gauge.animation-duration-rule
+
+ + + + + + + {{ 'widgets.gauge.animation-linear' | translate }} + + + {{ 'widgets.gauge.animation-quad' | translate }} + + + {{ 'widgets.gauge.animation-quint' | translate }} + + + {{ 'widgets.gauge.animation-cycle' | translate }} + + + {{ 'widgets.gauge.animation-bounce' | translate }} + + + {{ 'widgets.gauge.animation-elastic' | translate }} + + + {{ 'widgets.gauge.animation-dequad' | translate }} + + + {{ 'widgets.gauge.animation-dequint' | translate }} + + + {{ 'widgets.gauge.animation-decycle' | translate }} + + + {{ 'widgets.gauge.animation-debounce' | translate }} + + + {{ 'widgets.gauge.animation-delastic' | translate }} + + + +
+
- - +
+ -
- widgets.gauge.radial-gauge-settings -
- - widgets.gauge.start-ticks-angle - +
+
widgets.gauge.radial-gauge-settings
+
+
widgets.gauge.start-ticks-angle
+ + + +
+
+
widgets.gauge.ticks-angle
+ + - - widgets.gauge.ticks-angle - +
+
+
widgets.gauge.needle-circle-size
+ + -
- - widgets.gauge.needle-circle-size - - -
+
+ + +
-
- widgets.gauge.linear-gauge-settings -
- - widgets.gauge.bar-stroke-width - - - - -
-
- +
+
widgets.gauge.linear-gauge-settings
+
+
widgets.gauge.bar-stroke
+
+ + + + + +
+
+ +
+
widgets.gauge.bar-background-color
+ - +
+
+
widgets.gauge.bar-background-color-end
+ -
-
- + + +
+
widgets.gauge.progress-bar-color-start
+ - +
+
+
widgets.gauge.progress-bar-color-end
+ -
-
+ + + + +
+ + +
+
{{ 'widgets.gauge.arrow-and-scale-color' | translate }}
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.ts index 07f2be69bd..88f3e4f911 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -15,11 +15,9 @@ /// import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; -import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { GaugeHighlight } from '@home/components/widget/lib/settings/gauge/gauge-highlight.component'; -import { CdkDragDrop } from '@angular/cdk/drag-drop'; export class AnalogueGaugeWidgetSettingsComponent extends WidgetSettingsComponent { @@ -116,22 +114,27 @@ export class AnalogueGaugeWidgetSettingsComponent extends WidgetSettingsComponen maxValue: [settings.maxValue, []], majorTicksCount: [settings.majorTicksCount, [Validators.min(0)]], colorMajorTicks: [settings.colorMajorTicks, []], - minorTicks: [settings.majorTicksCount, [Validators.min(0)]], + minorTicks: [settings.minorTicks, [Validators.min(0)]], colorMinorTicks: [settings.colorMinorTicks, []], numbersFont: [settings.numbersFont, []], + numbersColor: [settings.numbersFont.color, []], // Unit title settings showUnitTitle: [settings.showUnitTitle, []], unitTitle: [settings.unitTitle, []], titleFont: [settings.titleFont, []], + titleColor: [settings.titleFont.color, []], // Units settings unitsFont: [settings.unitsFont, []], + unitsColor: [settings.unitsFont.color, []], // Value box settings valueBox: [settings.valueBox, []], valueInt: [settings.valueInt, [Validators.min(0)]], valueFont: [settings.valueFont, []], + valueColor: [settings.valueFont.color, []], + valueColorShadow: [settings.valueFont.shadowColor, []], colorValueBoxRect: [settings.colorValueBoxRect, []], colorValueBoxRectEnd: [settings.colorValueBoxRectEnd, []], colorValueBoxBackground: [settings.colorValueBoxBackground, []], @@ -149,7 +152,7 @@ export class AnalogueGaugeWidgetSettingsComponent extends WidgetSettingsComponen // Highlights settings highlightsWidth: [settings.highlightsWidth, [Validators.min(0)]], - highlights: this.prepareHighlightsFormArray(settings.highlights), + highlights: [settings.highlights, []], // Animation settings animation: [settings.animation, []], @@ -170,13 +173,16 @@ export class AnalogueGaugeWidgetSettingsComponent extends WidgetSettingsComponen if (showUnitTitle) { this.analogueGaugeWidgetSettingsForm.get('unitTitle').enable(); this.analogueGaugeWidgetSettingsForm.get('titleFont').enable(); + this.analogueGaugeWidgetSettingsForm.get('titleColor').enable(); } else { this.analogueGaugeWidgetSettingsForm.get('unitTitle').disable(); this.analogueGaugeWidgetSettingsForm.get('titleFont').disable(); + this.analogueGaugeWidgetSettingsForm.get('titleColor').disable(); } if (valueBox) { this.analogueGaugeWidgetSettingsForm.get('valueInt').enable(); this.analogueGaugeWidgetSettingsForm.get('valueFont').enable(); + this.analogueGaugeWidgetSettingsForm.get('valueColor').enable(); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxRect').enable(); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxRectEnd').enable(); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxBackground').enable(); @@ -184,6 +190,7 @@ export class AnalogueGaugeWidgetSettingsComponent extends WidgetSettingsComponen } else { this.analogueGaugeWidgetSettingsForm.get('valueInt').disable(); this.analogueGaugeWidgetSettingsForm.get('valueFont').disable(); + this.analogueGaugeWidgetSettingsForm.get('valueColor').disable(); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxRect').disable(); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxRectEnd').disable(); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxBackground').disable(); @@ -198,8 +205,10 @@ export class AnalogueGaugeWidgetSettingsComponent extends WidgetSettingsComponen } this.analogueGaugeWidgetSettingsForm.get('unitTitle').updateValueAndValidity({emitEvent}); this.analogueGaugeWidgetSettingsForm.get('titleFont').updateValueAndValidity({emitEvent}); + this.analogueGaugeWidgetSettingsForm.get('titleColor').updateValueAndValidity({emitEvent}); this.analogueGaugeWidgetSettingsForm.get('valueInt').updateValueAndValidity({emitEvent}); this.analogueGaugeWidgetSettingsForm.get('valueFont').updateValueAndValidity({emitEvent}); + this.analogueGaugeWidgetSettingsForm.get('valueColor').updateValueAndValidity({emitEvent}); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxRect').updateValueAndValidity({emitEvent}); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxRectEnd').updateValueAndValidity({emitEvent}); this.analogueGaugeWidgetSettingsForm.get('colorValueBoxBackground').updateValueAndValidity({emitEvent}); @@ -208,50 +217,17 @@ export class AnalogueGaugeWidgetSettingsComponent extends WidgetSettingsComponen this.analogueGaugeWidgetSettingsForm.get('animationRule').updateValueAndValidity({emitEvent}); } - protected doUpdateSettings(settingsForm: UntypedFormGroup, settings: WidgetSettings) { - settingsForm.setControl('highlights', this.prepareHighlightsFormArray(settings.highlights), {emitEvent: false}); - } - - private prepareHighlightsFormArray(highlights: GaugeHighlight[] | undefined): UntypedFormArray { - const highlightsControls: Array = []; - if (highlights) { - highlights.forEach((highlight) => { - highlightsControls.push(this.fb.control(highlight, [Validators.required])); - }); + protected prepareOutputSettings(settings) { + settings.numbersFont.color = this.analogueGaugeWidgetSettingsForm.get('numbersColor').value; + if (settings.titleFont) { + settings.titleFont.color = this.analogueGaugeWidgetSettingsForm.get('titleColor').value; } - return this.fb.array(highlightsControls); - } - - highlightsFormArray(): UntypedFormArray { - return this.analogueGaugeWidgetSettingsForm.get('highlights') as UntypedFormArray; - } - - public trackByHighlightControl(index: number, highlightControl: AbstractControl): any { - return highlightControl; - } - - public removeHighlight(index: number) { - (this.analogueGaugeWidgetSettingsForm.get('highlights') as UntypedFormArray).removeAt(index); - } - - public addHighlight() { - const highlight: GaugeHighlight = { - from: null, - to: null, - color: null - }; - const highlightsArray = this.analogueGaugeWidgetSettingsForm.get('highlights') as UntypedFormArray; - const highlightControl = this.fb.control(highlight, [Validators.required]); - (highlightControl as any).new = true; - highlightsArray.push(highlightControl); - this.analogueGaugeWidgetSettingsForm.updateValueAndValidity(); - } - - highlightDrop(event: CdkDragDrop) { - const highlightsArray = this.analogueGaugeWidgetSettingsForm.get('highlights') as UntypedFormArray; - const highlight = highlightsArray.at(event.previousIndex); - highlightsArray.removeAt(event.previousIndex); - highlightsArray.insert(event.currentIndex, highlight); + settings.unitsFont.color = this.analogueGaugeWidgetSettingsForm.get('unitsColor').value; + if (settings.valueFont) { + settings.valueFont.color = this.analogueGaugeWidgetSettingsForm.get('valueColor').value; + settings.valueFont.shadowColor = this.analogueGaugeWidgetSettingsForm.get('valueColorShadow').value; + } + return settings; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts index 3b56ef44e0..fb4406771d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts index a8204085e0..68d03100d6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html index 9d37dd6f00..e3592e73ad 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html @@ -1,6 +1,6 @@ - - -
- -
-
{{ highlightRangeText() }}
-
-
-
-
-
- - -
-
- -
- -
-
- - widgets.gauge.highlight-from - - - - widgets.gauge.highlight-to - - -
- - -
-
-
-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.ts deleted file mode 100644 index e5c1ec9fbc..0000000000 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.ts +++ /dev/null @@ -1,116 +0,0 @@ -/// -/// Copyright © 2016-2023 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; -import { PageComponent } from '@shared/components/page.component'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { TranslateService } from '@ngx-translate/core'; -import { isNumber } from '@core/utils'; - -export interface GaugeHighlight { - from: number; - to: number; - color: string; -} - -@Component({ - selector: 'tb-gauge-highlight', - templateUrl: './gauge-highlight.component.html', - styleUrls: ['./gauge-highlight.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => GaugeHighlightComponent), - multi: true - } - ] -}) -export class GaugeHighlightComponent extends PageComponent implements OnInit, ControlValueAccessor { - - @Input() - disabled: boolean; - - @Input() - expanded = false; - - @Output() - removeHighlight = new EventEmitter(); - - private modelValue: GaugeHighlight; - - private propagateChange = null; - - public gaugeHighlightFormGroup: UntypedFormGroup; - - constructor(protected store: Store, - private translate: TranslateService, - private fb: UntypedFormBuilder) { - super(store); - } - - ngOnInit(): void { - this.gaugeHighlightFormGroup = this.fb.group({ - from: [null, []], - to: [null, []], - color: [null, []] - }); - this.gaugeHighlightFormGroup.valueChanges.subscribe(() => { - this.updateModel(); - }); - } - - registerOnChange(fn: any): void { - this.propagateChange = fn; - } - - registerOnTouched(fn: any): void { - } - - setDisabledState(isDisabled: boolean): void { - this.disabled = isDisabled; - if (isDisabled) { - this.gaugeHighlightFormGroup.disable({emitEvent: false}); - } else { - this.gaugeHighlightFormGroup.enable({emitEvent: false}); - } - } - - writeValue(value: GaugeHighlight): void { - this.modelValue = value; - this.gaugeHighlightFormGroup.patchValue( - value, {emitEvent: false} - ); - } - - highlightRangeText(): string { - const value: GaugeHighlight = this.gaugeHighlightFormGroup.value; - const from = isNumber(value.from) ? value.from : 0; - const to = isNumber(value.to) ? value.to : 0; - return `${from} - ${to}`; - } - - private updateModel() { - const value: GaugeHighlight = this.gaugeHighlightFormGroup.value; - this.modelValue = value; - if (this.gaugeHighlightFormGroup.valid) { - this.propagateChange(this.modelValue); - } else { - this.propagateChange(null); - } - } -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.html index 82e74055e2..c91880c432 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.html @@ -1,6 +1,6 @@ + +
+
+
+
+
widgets.liquid-level-card.shape
+ + + {{ DataSourceTypeTranslations.get(type) | translate }} + + +
+ + + {{ ShapesTranslationMap.get(shape) | translate }} + + + +
+
widgets.liquid-level-card.shape-attribute-name
+ + +
+
+
widgets.liquid-level-card.shape-by-attribute
+
+
+
+
+
+
+
+
widgets.liquid-level-card.layout
+ + + {{ LevelCardLayoutTranslationMap.get(layout) | translate }} + + +
+
+
widgets.liquid-level-card.units
+
+
widgets.liquid-level-card.datasource-units
+ + +
+
+
widgets.liquid-level-card.widget-units
+
+ + + + {{ DataSourceTypeTranslations.get(type) | translate }} + + + + + + + + + +
+
+
+
widgets.liquid-level-card.total-volume
+
+ + + + {{ DataSourceTypeTranslations.get(type) | translate }} + + + + + + + warning + + + + + + + + +
+
+
+
+
widgets.liquid-level-card.color-and-font
+
+
widgets.liquid-level-card.tank-color
+ + +
+
+
widgets.liquid-level-card.liquid-color
+ + +
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widgets.liquid-level-card.value
+
+ + + + +
+
+
+
widgets.liquid-level-card.total-volume
+
+ + + + +
+
+
+ + {{ 'widgets.liquid-level-card.background-overlay' | translate }} + + + +
+
+
+ + + + + {{ 'widget-config.tooltip' | translate }} + + + + +
+ + {{ 'widgets.liquid-level-card.level' | translate }} + +
+ + + + +
widget-config.decimals-suffix
+
+ + + + +
+
+
+ + {{ 'widgets.value-card.date' | translate }} + +
+ + + + + +
+
+
+
widgets.liquid-level-card.tooltip-background
+ + +
+
+
widgets.liquid-level-card.background-blur
+ + +
px
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts new file mode 100644 index 0000000000..5ed3bea446 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts @@ -0,0 +1,306 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, Injector, ViewChild } from '@angular/core'; +import { + DataKey, + Datasource, + DatasourceType, + WidgetSettings, + WidgetSettingsComponent +} from '@shared/models/widget.models'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { formatValue, isDefined } from '@core/utils'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models'; +import { + CapacityUnits, + createShapeLayout, + fetchEntityKeys, + fetchEntityKeysForDevice, + levelCardDefaultSettings, + LevelCardLayout, + levelCardLayoutTranslations, + LiquidWidgetDataSourceType, + LiquidWidgetDataSourceTypeTranslations, + loadSvgShapesMapping, + optionsFilter, + Shapes, + ShapesTranslations, + updatedFormSettingsValidators +} from '@home/components/widget/lib/indicator/liquid-level-widget.models'; +import { UnitsType } from '@shared/models/unit.models'; +import { ImageCardsSelectComponent } from '@home/components/widget/lib/settings/common/image-cards-select.component'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { Observable, of, ReplaySubject } from 'rxjs'; +import { map, share, tap } from 'rxjs/operators'; +import { ResourcesService } from '@core/services/resources.service'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityService } from '@core/http/entity.service'; + +@Component({ + selector: 'liquid-level-card-widget-settings', + templateUrl: './liquid-level-card-widget-settings.component.html', + styleUrls: [] +}) +export class LiquidLevelCardWidgetSettingsComponent extends WidgetSettingsComponent { + + @ViewChild('layoutsImageCardsSelect', { static: false }) layoutsImageCardsSelect: ImageCardsSelectComponent; + + @ViewChild('shapesImageCardsSelect', { static: false }) shapesImageCardsSelect: ImageCardsSelectComponent; + + public get volumeInput(): boolean { + const datasourceUnits = this.levelCardWidgetSettingsForm.get('datasourceUnits').value; + const layout: LevelCardLayout = this.levelCardWidgetSettingsForm.get('layout').value; + + if (layout === LevelCardLayout.absolute) { + return true; + } + return datasourceUnits !== CapacityUnits.percent; + } + + private get datasource(): Datasource { + if (this.widgetConfig.config.datasources && this.widgetConfig.config.datasources) { + return this.widgetConfig.config.datasources[0]; + } else { + return null; + } + } + + LevelCardLayout = LevelCardLayout; + LevelCardLayouts = Object.values(LevelCardLayout) as LevelCardLayout[]; + LevelCardLayoutTranslationMap = levelCardLayoutTranslations; + + DataSourceType = LiquidWidgetDataSourceType; + DataSourceTypes = Object.values(LiquidWidgetDataSourceType) as LiquidWidgetDataSourceType[]; + DataSourceTypeTranslations = LiquidWidgetDataSourceTypeTranslations; + + shapes = Object.values(Shapes) as Shapes[]; + shapesImageMap: Map = new Map(); + ShapesTranslationMap = ShapesTranslations; + + unitsType = UnitsType; + + levelCardWidgetSettingsForm: FormGroup; + + valuePreviewFn = this._valuePreviewFn.bind(this); + + tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this); + + totalVolumeValuePreviewFn = this._totalVolumeValuePreviewFn.bind(this); + + datePreviewFn = this._datePreviewFn.bind(this); + + private keySearchText: string; + private latestKeySearchTextResult: Array; + + private functionTypeKeys: Array = []; + + private lastKeysId: string; + private lastFetchedKeys: Array; + + constructor(protected store: Store, + private $injector: Injector, + private fb: FormBuilder, + private resourcesService: ResourcesService, + private sanitizer: DomSanitizer, + private cd: ChangeDetectorRef, + private utils: UtilsService, + private entityService: EntityService) { + super(store); + } + + protected settingsForm(): FormGroup { + return this.levelCardWidgetSettingsForm; + } + + protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) { + this.createSvgShapesMapping(); + + for (const type of this.utils.getPredefinedFunctionsList()) { + this.functionTypeKeys.push({ + name: type, + type: DataKeyType.function + }); + } + } + + protected defaultSettings(): WidgetSettings { + return levelCardDefaultSettings; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.levelCardWidgetSettingsForm = this.fb.group({ + tankSelectionType: [settings.tankSelectionType, []], + selectedShape: [settings.selectedShape, [Validators.required]], + shapeAttributeName: [settings.shapeAttributeName, [Validators.required]], + tankColor: [settings.tankColor, []], + datasourceUnits: [settings.datasourceUnits, [Validators.required]], + + layout: [settings.layout, []], + + volumeSource: [settings.volumeSource, []], + volumeConstant: [settings.volumeConstant, [Validators.required, Validators.min(0.1)]], + volumeAttributeName: [settings.volumeAttributeName, [Validators.required]], + volumeUnits: [settings.volumeUnits, [Validators.required]], + volumeFont: [settings.volumeFont, []], + volumeColor: [settings.volumeColor, []], + valueFont: [settings.valueFont, []], + valueColor: [settings.valueColor, []], + units: [settings.units, [Validators.required]], + widgetUnitsSource: [settings.widgetUnitsSource, [Validators.required]], + widgetUnitsAttributeName: [settings.widgetUnitsAttributeName, [Validators.required]], + showBackgroundOverlay: [settings.showBackgroundOverlay, []], + backgroundOverlayColor: [settings.backgroundOverlayColor, []], + + liquidColor: [settings.liquidColor, []], + background: [settings.background], + + showTooltip: [settings.showTooltip, []], + showTooltipLevel: [settings.showTooltipLevel, []], + tooltipUnits: [settings.tooltipUnits, []], + tooltipLevelDecimals: [settings.tooltipLevelDecimals, []], + tooltipLevelFont: [settings.tooltipLevelFont, []], + tooltipLevelColor: [settings.tooltipLevelColor, []], + showTooltipDate: [settings.showTooltipDate, []], + tooltipDateFormat: [settings.tooltipDateFormat, []], + tooltipDateFont: [settings.tooltipDateFont, []], + tooltipDateColor: [settings.tooltipDateColor, []], + tooltipBackgroundColor: [settings.tooltipBackgroundColor, []], + tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []], + }); + + this.levelCardWidgetSettingsForm.get('selectedShape').valueChanges.subscribe(() => { + this.cd.detectChanges(); + this.layoutsImageCardsSelect?.imageCardsSelectOptions.notifyOnChanges(); + }); + } + + protected validatorTriggers(): string[] { + return [ + 'showBackgroundOverlay', 'showTooltip', 'showTooltipLevel', 'tankSelectionType', 'datasourceUnits', + 'showTooltipDate', 'layout', 'volumeSource', 'widgetUnitsSource' + ]; + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + updatedFormSettingsValidators(this.levelCardWidgetSettingsForm); + + if (this.levelCardWidgetSettingsForm.get('showBackgroundOverlay').value) { + this.levelCardWidgetSettingsForm.get('backgroundOverlayColor').enable({emitEvent: false}); + } else { + this.levelCardWidgetSettingsForm.get('backgroundOverlayColor').disable({emitEvent: false}); + } + } + + private createSvgShapesMapping(): void { + loadSvgShapesMapping(this.resourcesService).subscribe(shapeMap => { + this.shapesImageMap = shapeMap; + + this.cd.detectChanges(); + this.layoutsImageCardsSelect?.imageCardsSelectOptions.notifyOnChanges(); + this.shapesImageCardsSelect?.imageCardsSelectOptions.notifyOnChanges(); + }); + } + + createShape(svg: string, layout: LevelCardLayout): SafeUrl { + return createShapeLayout(svg, layout, this.sanitizer); + } + + private _valuePreviewFn(): string { + const units: string = this.widgetConfig.config.units; + const decimals: number = this.widgetConfig.config.decimals; + return formatValue(32, decimals, units, true); + } + + private _tooltipValuePreviewFn() { + const units: string = this.levelCardWidgetSettingsForm.get('tooltipUnits').value; + const decimals: number = this.levelCardWidgetSettingsForm.get('tooltipLevelDecimals').value; + return formatValue(32, decimals, units, true); + } + + private _totalVolumeValuePreviewFn() { + const value = this.levelCardWidgetSettingsForm.get('volumeConstant').value; + const datasourceUnits = this.levelCardWidgetSettingsForm.get('datasourceUnits').value; + const decimals: number = this.widgetConfig.config.decimals; + let units: string = this.widgetConfig.config.units; + + if (datasourceUnits !== CapacityUnits.percent) { + units = datasourceUnits; + } + + return formatValue((isDefined(value) ? value : 500), decimals, units, true); + } + + private _datePreviewFn(): string { + const dateFormat: DateFormatSettings = this.levelCardWidgetSettingsForm.get('tooltipDateFormat').value; + const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat); + processor.update(Date.now()); + return processor.formatted; + } + + public fetchOptions(searchText: string): Observable> { + if (this.keySearchText !== searchText) { + this.keySearchText = searchText; + const dataKeyFilter = optionsFilter(this.keySearchText); + return this.getKeys().pipe( + tap(res => this.lastFetchedKeys !== res ? this.lastFetchedKeys = res : []), + map(name => name?.filter(dataKeyFilter).map(key => key.name)), + tap(res => this.latestKeySearchTextResult = res) + ); + } + return of(this.latestKeySearchTextResult); + } + + private getKeys(): Observable> { + let fetchObservable: Observable>; + if (this.datasource?.type === DatasourceType.function) { + fetchObservable = of(this.functionTypeKeys); + } else if (this.datasource?.type === DatasourceType.entity && this.datasource?.entityAliasId || + this.datasource?.type === DatasourceType.device && this.datasource?.deviceId) { + if (this.datasource?.type === DatasourceType.device) { + if (this.lastKeysId !== this.datasource?.deviceId || !this.lastFetchedKeys) { + this.lastKeysId = this.datasource.deviceId; + fetchObservable = fetchEntityKeysForDevice(this.datasource?.deviceId, [DataKeyType.attribute], + this.entityService); + } else { + fetchObservable = of(this.lastFetchedKeys); + } + } else { + if (this.lastKeysId !== this.datasource?.entityAliasId || !this.lastFetchedKeys) { + this.lastKeysId = this.datasource.entityAliasId; + fetchObservable = fetchEntityKeys(this.datasource?.entityAliasId, [DataKeyType.attribute], + this.entityService, this.aliasController); + } else { + fetchObservable = of(this.lastFetchedKeys); + } + } + } else { + fetchObservable = of([]); + } + return fetchObservable.pipe( + share({ + connector: () => new ReplaySubject(1), + resetOnError: false, + resetOnComplete: false, + resetOnRefCountZero: false + }) + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html index e5440793e9..4e49d9d5c9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html @@ -1,6 +1,6 @@
- - +
widgets.maps.image-map-background-from-entity-attribute
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts index e79735e70d..51cc5ae6d7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.html index 8c0eb5e6b2..edea61f456 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.html @@ -1,6 +1,6 @@ -
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.scss index eda98fe6ba..5f3b18a041 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts index d328e59ace..4ce5cf21d7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -24,12 +24,11 @@ import { OnInit, Renderer2, TemplateRef, - ViewChild, ViewEncapsulation + ViewChild, + ViewEncapsulation } from '@angular/core'; import { WidgetContext } from '@home/models/widget-component.models'; import { - centerValueLabel, - windDirectionLabel, windSpeedDirectionDefaultSettings, WindSpeedDirectionLayout, WindSpeedDirectionWidgetSettings @@ -38,8 +37,8 @@ import { backgroundStyle, ColorProcessor, ComponentStyle, - Font, getDataKey, - getDataKeyByLabel, + Font, + getDataKey, getSingleTsValueByDataKey, overlayStyle } from '@shared/models/widget-settings.models'; @@ -48,6 +47,9 @@ import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils'; import { ResizeObserver } from '@juggle/resize-observer'; import { Path, Svg, SVG, Text } from '@svgdotjs/svg.js'; import { DataKey } from '@shared/models/widget.models'; +import { Observable } from 'rxjs'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; const shapeSize = 180; const cx = shapeSize / 2; @@ -88,7 +90,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft centerValueColor: ColorProcessor; - backgroundStyle: ComponentStyle = {}; + backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; shapeResize$: ResizeObserver; @@ -110,6 +112,8 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft private centerValueText = 'N/A'; constructor(private widgetComponent: WidgetComponent, + private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, private renderer: Renderer2, private cd: ChangeDetectorRef) { } @@ -118,11 +122,8 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft this.ctx.$scope.windSpeedDirectionWidget = this; this.settings = {...windSpeedDirectionDefaultSettings, ...this.ctx.settings}; - this.windDirectionDataKey = getDataKeyByLabel(this.ctx.datasources, windDirectionLabel); - if (!this.windDirectionDataKey) { - this.windDirectionDataKey = getDataKey(this.ctx.datasources); - } - this.centerValueDataKey = getDataKeyByLabel(this.ctx.datasources, centerValueLabel); + this.windDirectionDataKey = getDataKey(this.ctx.datasources, 0); + this.centerValueDataKey = getDataKey(this.ctx.datasources, 1); if (this.centerValueDataKey) { this.decimals = this.ctx.decimals; @@ -139,7 +140,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft this.centerValueColor = ColorProcessor.fromSettings(this.settings.centerValueColor); - this.backgroundStyle = backgroundStyle(this.settings.background); + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0; @@ -169,21 +170,27 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft } public onDataUpdated() { - let centerValue = 0; + let value = 0; this.windDirection = 0; this.centerValueText = 'N/A'; - const windDirectionTsValue = getSingleTsValueByDataKey(this.ctx.data, this.windDirectionDataKey); - if (windDirectionTsValue && isDefinedAndNotNull(windDirectionTsValue[1]) && isNumeric(windDirectionTsValue[1])) { - this.windDirection = windDirectionTsValue[1]; + if (this.windDirectionDataKey) { + const windDirectionTsValue = getSingleTsValueByDataKey(this.ctx.data, this.windDirectionDataKey); + if (windDirectionTsValue && isDefinedAndNotNull(windDirectionTsValue[1]) && isNumeric(windDirectionTsValue[1])) { + this.windDirection = windDirectionTsValue[1]; + if (!this.centerValueDataKey) { + value = this.windDirection; + this.centerValueText = formatValue(value, 0, '', false) + '°'; + } + } } if (this.centerValueDataKey) { const centerValueTsValue = getSingleTsValueByDataKey(this.ctx.data, this.centerValueDataKey); if (centerValueTsValue && isDefinedAndNotNull(centerValueTsValue[1]) && isNumeric(centerValueTsValue[1])) { - centerValue = centerValueTsValue[1]; - this.centerValueText = formatValue(centerValue, this.decimals, '', true); + value = centerValueTsValue[1]; + this.centerValueText = formatValue(value, this.decimals, '', false); } } - this.centerValueColor.update(centerValue); + this.centerValueColor.update(value); this.renderValues(); } @@ -313,9 +320,8 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft } private renderCenterValueText() { - const text = this.centerValueDataKey ? this.centerValueText : formatValue(this.windDirection, 0, '') + '°'; this.centerValueTextNode.text(add => { - add.tspan(text).font({size: '24px'}); + add.tspan(this.centerValueText).font({size: '24px'}); if (this.units) { add.tspan(this.units).newLine().font({size: '14px'}); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts index c9fe2a84df..7801f80a17 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -103,6 +103,3 @@ export const windSpeedDirectionDefaultSettings: WindSpeedDirectionWidgetSettings } } }; - -export const windDirectionLabel = 'windDirection'; -export const centerValueLabel = 'centerValue'; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index f8023008f1..56a1868227 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -29,7 +29,13 @@ import { import cssjs from '@core/css/css'; import { UtilsService } from '@core/services/utils.service'; import { ModulesWithFactories, ResourcesService } from '@core/services/resources.service'; -import { Widget, widgetActionSources, WidgetControllerDescriptor, WidgetType } from '@shared/models/widget.models'; +import { + IWidgetSettingsComponent, + Widget, + widgetActionSources, + WidgetControllerDescriptor, + WidgetType +} from '@shared/models/widget.models'; import { catchError, map, mergeMap, switchMap, tap } from 'rxjs/operators'; import { isFunction, isUndefined } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; @@ -47,6 +53,8 @@ import moment from 'moment'; import { IModulesMap } from '@modules/common/modules-map.models'; import { HOME_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; import { widgetSettingsComponentsMap } from '@home/components/widget/lib/settings/widget-settings.module'; +import { basicWidgetConfigComponentsMap } from '@home/components/widget/config/basic/basic-widget-config.module'; +import { IBasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; @Injectable() export class WidgetComponentService { @@ -397,6 +405,7 @@ export class WidgetComponentService { private registerWidgetSettingsForms(widgetInfo: WidgetInfo, factories: ComponentFactory[]) { const directives: string[] = []; + const basicDirectives: string[] = []; if (widgetInfo.settingsDirective && widgetInfo.settingsDirective.length) { directives.push(widgetInfo.settingsDirective); } @@ -407,12 +416,19 @@ export class WidgetComponentService { directives.push(widgetInfo.latestDataKeySettingsDirective); } if (widgetInfo.basicModeDirective && widgetInfo.basicModeDirective.length) { - directives.push(widgetInfo.basicModeDirective); + basicDirectives.push(widgetInfo.basicModeDirective); } + + this.expandSettingComponentMap(widgetSettingsComponentsMap, directives, factories); + this.expandSettingComponentMap(basicWidgetConfigComponentsMap, basicDirectives, factories); + } + + private expandSettingComponentMap(settingsComponentsMap: {[key: string]: Type}, + directives: string[], factories: ComponentFactory[]): void { if (directives.length) { factories.filter((factory) => directives.includes(factory.selector)) .forEach((foundFactory) => { - widgetSettingsComponentsMap[foundFactory.selector] = foundFactory.componentType; + settingsComponentsMap[foundFactory.selector] = foundFactory.componentType; }); } } @@ -552,6 +568,12 @@ export class WidgetComponentService { if (isUndefined(result.typeParameters.hideDataSettings)) { result.typeParameters.hideDataSettings = false; } + if (!isFunction(result.typeParameters.defaultDataKeysFunction)) { + result.typeParameters.defaultDataKeysFunction = null; + } + if (!isFunction(result.typeParameters.defaultLatestDataKeysFunction)) { + result.typeParameters.defaultLatestDataKeysFunction = null; + } if (isFunction(widgetTypeInstance.actionSources)) { result.actionSources = widgetTypeInstance.actionSources(); } else { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 1e3e1f6a2a..3ba6b58799 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -64,6 +64,12 @@ import { import { SignalStrengthWidgetComponent } from '@home/components/widget/lib/indicator/signal-strength-widget.component'; import { ValueChartCardWidgetComponent } from '@home/components/widget/lib/cards/value-chart-card-widget.component'; import { ProgressBarWidgetComponent } from '@home/components/widget/lib/cards/progress-bar-widget.component'; +import { LiquidLevelWidgetComponent } from '@home/components/widget/lib/indicator/liquid-level-widget.component'; +import { DoughnutWidgetComponent } from '@home/components/widget/lib/chart/doughnut-widget.component'; +import { RangeChartWidgetComponent } from '@home/components/widget/lib/chart/range-chart-widget.component'; +import { + BarChartWithLabelsWidgetComponent +} from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.component'; @NgModule({ declarations: @@ -102,7 +108,11 @@ import { ProgressBarWidgetComponent } from '@home/components/widget/lib/cards/pr WindSpeedDirectionWidgetComponent, SignalStrengthWidgetComponent, ValueChartCardWidgetComponent, - ProgressBarWidgetComponent + ProgressBarWidgetComponent, + LiquidLevelWidgetComponent, + DoughnutWidgetComponent, + RangeChartWidgetComponent, + BarChartWithLabelsWidgetComponent ], imports: [ CommonModule, @@ -145,7 +155,11 @@ import { ProgressBarWidgetComponent } from '@home/components/widget/lib/cards/pr WindSpeedDirectionWidgetComponent, SignalStrengthWidgetComponent, ValueChartCardWidgetComponent, - ProgressBarWidgetComponent + ProgressBarWidgetComponent, + LiquidLevelWidgetComponent, + DoughnutWidgetComponent, + RangeChartWidgetComponent, + BarChartWithLabelsWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index cdd1d8e2da..e49b769fee 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -1,6 +1,6 @@ -
- - - -
- admin.general-settings -
-
-
- - -
- - -
- - admin.base-url - - - {{ 'admin.base-url-required' | translate }} - - - + + + +
+ admin.general-settings +
+
+
+ + +
+ + +
+ + admin.base-url + + + {{ 'admin.base-url-required' | translate }} + + +
+ {{ 'admin.prohibit-different-url' | translate }} - -
admin.prohibit-different-url-hint
-
- + +
+
+
+ + +
+ +
+
+ + + +
admin.device-connectivity.device-connectivity
+
+
+ + +
+ +
+
+ + {{ "admin.device-connectivity.http-s" | translate }} + {{ 'admin.device-connectivity.mqtt-s' | translate }} + {{ 'admin.device-connectivity.coap-s' | translate }} + +
{{ 'admin.device-connectivity.hint' | translate }}
+
+
+ + + + + + + + + + + + + +
+ + {{ 'admin.device-connectivity.' + protocol | translate }} + +
+ + admin.device-connectivity.host + + + + admin.device-connectivity.port + + + {{ 'admin.device-connectivity.port-pattern' | translate }} + + + {{ 'admin.device-connectivity.port-range' | translate }} + + +
-
+ +
+ + +
-
-
-
+
+ + diff --git a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss index 4b2d51d1e9..9546130a6f 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -13,11 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../../../../../scss/constants"; + :host { -} + .settings-card { + .toggle-group { + max-width: 670px; + width: 100%; + margin: 0 auto; + + .tb-form-hint { + text-align: center; + } + } -:host ::ng-deep { - .mat-checkbox-layout { - white-space: normal; + @media #{$mat-xs} { + .tb-form-row { + gap: 0; + } + } } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts index 9aee40d431..3af1936170 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -14,62 +14,130 @@ /// limitations under the License. /// -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { Router } from '@angular/router'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { AdminSettings, GeneralSettings } from '@shared/models/settings.models'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { + AdminSettings, + DeviceConnectivityProtocol, + DeviceConnectivitySettings, + GeneralSettings +} from '@shared/models/settings.models'; import { AdminService } from '@core/http/admin.service'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'tb-general-settings', templateUrl: './general-settings.component.html', styleUrls: ['./general-settings.component.scss', './settings-card.scss'] }) -export class GeneralSettingsComponent extends PageComponent implements OnInit, HasConfirmForm { +export class GeneralSettingsComponent extends PageComponent implements HasConfirmForm, OnDestroy { - generalSettings: UntypedFormGroup; - adminSettings: AdminSettings; + generalSettings: FormGroup; + deviceConnectivitySettingsForm: FormGroup; + + protocol: DeviceConnectivityProtocol = 'http'; + + private adminSettings: AdminSettings; + private deviceConnectivitySettings: AdminSettings; + + private readonly destroy$ = new Subject(); constructor(protected store: Store, - private router: Router, private adminService: AdminService, - public fb: UntypedFormBuilder) { + public fb: FormBuilder) { super(store); + this.buildGeneralServerSettingsForm(); + this.adminService.getAdminSettings('general') + .subscribe(adminSettings => this.processGeneralSettings(adminSettings)); + this.buildDeviceConnectivitySettingsForm(); + this.adminService.getAdminSettings('connectivity') + .subscribe(deviceConnectivitySettings => this.processDeviceConnectivitySettings(deviceConnectivitySettings)); } - ngOnInit() { - this.buildGeneralServerSettingsForm(); - this.adminService.getAdminSettings('general').subscribe( - (adminSettings) => { - this.adminSettings = adminSettings; - this.generalSettings.reset(this.adminSettings.jsonValue); - } - ); + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); } - buildGeneralServerSettingsForm() { + private buildGeneralServerSettingsForm() { this.generalSettings = this.fb.group({ baseUrl: ['', [Validators.required]], prohibitDifferentUrl: ['',[]] }); } + private buildDeviceConnectivitySettingsForm() { + this.deviceConnectivitySettingsForm = this.fb.group({ + http: this.buildDeviceConnectivityInfoForm(), + https: this.buildDeviceConnectivityInfoForm(), + mqtt: this.buildDeviceConnectivityInfoForm(), + mqtts: this.buildDeviceConnectivityInfoForm(), + coap: this.buildDeviceConnectivityInfoForm(), + coaps: this.buildDeviceConnectivityInfoForm() + }); + } + + private buildDeviceConnectivityInfoForm(): FormGroup { + const formGroup = this.fb.group({ + enabled: [false, []], + host: [{value: '', disabled: true}], + port: [{value: null, disabled: true}, [Validators.min(1), Validators.max(65535), Validators.pattern('[0-9]*')]] + }); + formGroup.get('enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + formGroup.get('host').enable({emitEvent: false}); + formGroup.get('port').enable({emitEvent: false}); + } else { + formGroup.get('host').disable({emitEvent: false}); + formGroup.get('port').disable({emitEvent: false}); + } + }); + return formGroup; + } + save(): void { this.adminSettings.jsonValue = {...this.adminSettings.jsonValue, ...this.generalSettings.value}; - this.adminService.saveAdminSettings(this.adminSettings).subscribe( - (adminSettings) => { - this.adminSettings = adminSettings; - this.generalSettings.reset(this.adminSettings.jsonValue); - } - ); + this.adminService.saveAdminSettings(this.adminSettings) + .subscribe(adminSettings => this.processGeneralSettings(adminSettings)); + } + + saveDeviceConnectivitySettings(): void { + this.deviceConnectivitySettings.jsonValue = { + ...this.deviceConnectivitySettings.jsonValue, + ...this.deviceConnectivitySettingsForm.getRawValue() + }; + this.adminService.saveAdminSettings(this.deviceConnectivitySettings) + .subscribe(deviceConnectivitySettings => this.processDeviceConnectivitySettings(deviceConnectivitySettings)); + } + + discardGeneralSettings(): void { + this.generalSettings.reset(this.adminSettings.jsonValue); + } + + discardDeviceConnectivitySettings(): void { + this.deviceConnectivitySettingsForm.reset(this.deviceConnectivitySettings.jsonValue); + } + + private processGeneralSettings(generalSettings: AdminSettings): void { + this.adminSettings = generalSettings; + this.generalSettings.reset(this.adminSettings.jsonValue); + } + + private processDeviceConnectivitySettings(deviceConnectivitySettings: AdminSettings): void { + this.deviceConnectivitySettings = deviceConnectivitySettings; + this.deviceConnectivitySettingsForm.reset(this.deviceConnectivitySettings.jsonValue); } - confirmForm(): UntypedFormGroup { - return this.generalSettings; + confirmForm(): FormGroup { + return this.generalSettings.dirty ? this.generalSettings : this.deviceConnectivitySettingsForm; } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html index 42fd18dbae..d928712547 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html @@ -1,6 +1,6 @@ -
+ -

widget.save-widget-type-as

+

widget.save-widget-as

diff --git a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.scss index 686ee95c17..5d17e091d5 100644 --- a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.scss +++ b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. diff --git a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts index c29d66b1cc..da539bd328 100644 --- a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -21,6 +21,7 @@ export interface AlertDialogData { title: string; message: string; ok: string; + textMode?: boolean; } @Component({ diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html index 7cb184ebeb..3d37d83c15 100644 --- a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html @@ -1,6 +1,6 @@
-
-
- -
{{ noFileText }}
-
{{ fileName }}
+
+ +
{{ noFileText }}
+
{{ fileName }}
+
dashboard.maximum-upload-file-size
+
diff --git a/ui-ngx/src/app/shared/components/file-input.component.scss b/ui-ngx/src/app/shared/components/file-input.component.scss index e172abd6e9..2aa4190823 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.scss +++ b/ui-ngx/src/app/shared/components/file-input.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -21,9 +21,13 @@ $previewSize: 100px !default; .tb-container { margin-top: 0; + padding: 0 0 16px; + display: flex; + flex-direction: column; + gap: 8px; label.tb-title { display: flex; - padding-bottom: 8px; + padding-bottom: 0; } } @@ -78,19 +82,46 @@ $previewSize: 100px !default; text-align: center; .mat-icon { margin-right: 17px; + color: rgba(0,0,0,0.12); } } } + + .tb-file-info-container { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 13px; + font-style: normal; + line-height: 16px; + letter-spacing: normal; + } + + .tb-file-name { + color: rgba(0, 0, 0, 0.54); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + .tb-file-hint { + color: rgba(0, 0, 0, 0.38); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } } :host ::ng-deep { - button.browse-file { + button.mat-mdc-button.mat-mdc-button-base.browse-file { padding: 0; + min-width: 0; + height: 24px; font-size: 16px; label { display: block; cursor: pointer; - padding: 0 16px; + padding: 0 8px; } } diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index 8e70ca88ba..b385cf109a 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -32,10 +32,12 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Subscription } from 'rxjs'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { FlowDirective } from '@flowjs/ngx-flow'; import { TranslateService } from '@ngx-translate/core'; import { UtilsService } from '@core/services/utils.service'; +import { DialogService } from '@core/services/dialog.service'; +import { FileSizePipe } from '@shared/pipe/file-size.pipe'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-file-input', @@ -73,43 +75,28 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, dropLabel: string; @Input() - contentConvertFunction: (content: string) => any; - - private requiredValue: boolean; - - get required(): boolean { - return this.requiredValue; - } + maxSizeByte: number; @Input() - set required(value: boolean) { - const newVal = coerceBooleanProperty(value); - if (this.requiredValue !== newVal) { - this.requiredValue = newVal; - } - } - - private requiredAsErrorValue: boolean; + contentConvertFunction: (content: string) => any; - get requiredAsError(): boolean { - return this.requiredAsErrorValue; - } + @Input() + @coerceBoolean() + required: boolean; @Input() - set requiredAsError(value: boolean) { - const newVal = coerceBooleanProperty(value); - if (this.requiredAsErrorValue !== newVal) { - this.requiredAsErrorValue = newVal; - } - } + @coerceBoolean() + requiredAsError: boolean; @Input() + @coerceBoolean() disabled: boolean; @Input() existingFileName: string; @Input() + @coerceBoolean() readAsBinary = false; @Input() @@ -148,7 +135,9 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, constructor(protected store: Store, private utils: UtilsService, - public translate: TranslateService) { + private translate: TranslateService, + private dialog: DialogService, + private fileSize: FileSizePipe) { super(store); } @@ -156,11 +145,24 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.autoUploadSubscription = this.flow.events$.subscribe(event => { if (event.type === 'filesAdded') { const readers = []; + let showMaxSizeAlert = false; (event.event[0] as flowjs.FlowFile[]).forEach(file => { if (this.filterFile(file)) { - readers.push(this.readerAsFile(file)); + if (this.checkMaxSize(file)) { + readers.push(this.readerAsFile(file)); + } else { + showMaxSizeAlert = true; + } } }); + + if (showMaxSizeAlert) { + this.dialog.alert( + this.translate.instant('dashboard.cannot-upload-file'), + this.translate.instant('dashboard.maximum-upload-file-size', {size: this.fileSize.transform(this.maxSizeByte)}) + ).subscribe(() => { }); + } + if (readers.length) { Promise.all(readers).then((files) => { files = files.filter(file => file.fileContent != null || file.files != null); @@ -218,6 +220,10 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, }); } + private checkMaxSize(file: flowjs.FlowFile): boolean { + return !this.maxSizeByte || file.size <= this.maxSizeByte; + } + private filterFile(file: flowjs.FlowFile): boolean { if (this.allowedExtensions) { return this.allowedExtensions.split(',').indexOf(file.getExtension()) > -1; diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html index 20820ac320..7d9c0c33d5 100644 --- a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html @@ -1,6 +1,6 @@ - - + +
-
+
- +
diff --git a/ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.scss b/ui-ngx/src/app/shared/components/grid/scroll-grid.component.scss similarity index 91% rename from ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.scss rename to ui-ngx/src/app/shared/components/grid/scroll-grid.component.scss index c7a6e2d4b7..47d7629f7a 100644 --- a/ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.scss +++ b/ui-ngx/src/app/shared/components/grid/scroll-grid.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -18,6 +18,7 @@ .cdk-virtual-scroll-content-wrapper { display: flex; flex-direction: column; + width: 100%; } .cdk-virtual-scroll-spacer { height: auto !important; @@ -28,5 +29,6 @@ } .tb-scroll-grid-item-container { flex: 1; + min-width: 0; } } diff --git a/ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts b/ui-ngx/src/app/shared/components/grid/scroll-grid.component.ts similarity index 54% rename from ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts rename to ui-ngx/src/app/shared/components/grid/scroll-grid.component.ts index 3ca42bba07..e876b06694 100644 --- a/ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts +++ b/ui-ngx/src/app/shared/components/grid/scroll-grid.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -15,10 +15,10 @@ /// import { - AfterViewInit, + AfterViewInit, ChangeDetectorRef, Component, Input, - OnChanges, + OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, @@ -30,10 +30,18 @@ import { GridEntitiesFetchFunction, ScrollGridColumns, ScrollGridDatasource -} from '@home/models/datasource/scroll-grid-datasource'; +} from '@shared/components/grid/scroll-grid-datasource'; import { BreakpointObserver } from '@angular/cdk/layout'; import { isObject } from '@app/core/utils'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { ResizeObserver } from '@juggle/resize-observer'; + +export type ItemSizeFunction = (itemWidth: number) => number; + +export interface ItemSizeStrategy { + defaultItemSize: number; + itemSizeFunction: ItemSizeFunction; +} @Component({ selector: 'tb-scroll-grid', @@ -41,13 +49,13 @@ import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; styleUrls: ['./scroll-grid.component.scss'], encapsulation: ViewEncapsulation.None }) -export class ScrollGridComponent implements OnInit, AfterViewInit, OnChanges { +export class ScrollGridComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { @ViewChild('viewport') viewport: CdkVirtualScrollViewport; @Input() - columns: ScrollGridColumns = {columns: 1}; + columns: ScrollGridColumns | number = 1; @Input() fetchFunction: GridEntitiesFetchFunction; @@ -56,7 +64,7 @@ export class ScrollGridComponent implements OnInit, AfterViewInit, OnChang filter: F; @Input() - itemSize = 200; + itemSize: number | ItemSizeStrategy = 200; @Input() gap = 12; @@ -75,17 +83,37 @@ export class ScrollGridComponent implements OnInit, AfterViewInit, OnChang dataSource: ScrollGridDatasource; + calculatedItemSize: number; + minBuffer: number; + maxBuffer: number; + + private contentResize$: ResizeObserver; + constructor(private breakpointObserver: BreakpointObserver, + private cd: ChangeDetectorRef, private renderer: Renderer2) { } ngOnInit(): void { + if (typeof this.itemSize === 'number') { + this.calculatedItemSize = this.itemSize; + } else { + this.calculatedItemSize = this.itemSize.defaultItemSize; + } + this.minBuffer = this.calculatedItemSize; + this.maxBuffer = this.calculatedItemSize * 2; this.dataSource = new ScrollGridDatasource(this.breakpointObserver, this.columns, this.fetchFunction, this.filter); } ngAfterViewInit() { this.renderer.setStyle(this.viewport._contentWrapper.nativeElement, 'gap', this.gap + 'px'); this.renderer.setStyle(this.viewport._contentWrapper.nativeElement, 'padding', this.gap + 'px'); + if (!(typeof this.itemSize === 'number')) { + this.contentResize$ = new ResizeObserver(() => { + this.onContentResize(); + }); + this.contentResize$.observe(this.viewport._contentWrapper.nativeElement); + } } ngOnChanges(changes: SimpleChanges): void { @@ -97,7 +125,43 @@ export class ScrollGridComponent implements OnInit, AfterViewInit, OnChang } } + ngOnDestroy() { + if (this.contentResize$) { + this.contentResize$.disconnect(); + } + } + isObject(value: any): boolean { return isObject(value); } + + trackByItemsRow(index: number, itemsRow: T[]): number { + return index; + } + + trackByItem(index: number, item: T): T { + return item; + } + + public update() { + this.dataSource.update(); + } + + public updateItem(index: number, item: T) { + this.dataSource.updateItem(index, item); + } + + public deleteItem(index: number) { + this.dataSource.deleteItem(index); + } + + private onContentResize() { + const contentWidth = this.viewport._contentWrapper.nativeElement.getBoundingClientRect().width; + const columns = this.dataSource.currentColumns; + const itemWidth = (contentWidth - this.gap * (columns + 1)) / columns; + this.calculatedItemSize = (this.itemSize as ItemSizeStrategy).itemSizeFunction(itemWidth); + this.minBuffer = this.calculatedItemSize; + this.maxBuffer = this.calculatedItemSize * 2; + this.cd.markForCheck(); + } } diff --git a/ui-ngx/src/app/shared/components/help-markdown.component.html b/ui-ngx/src/app/shared/components/help-markdown.component.html index cba58b60f4..55a8786d0e 100644 --- a/ui-ngx/src/app/shared/components/help-markdown.component.html +++ b/ui-ngx/src/app/shared/components/help-markdown.component.html @@ -1,6 +1,6 @@ + +

{{ 'image.embed-image' | translate }}

+ + +
+ + +
+
+
+ + + +
+ +
{{ 'image.embed-to-html' | translate }}
+
+
+
image.embed-to-html
+
+
+ +
+ +
+
+
+
+
image.embed-to-angular-template
+
+ +
+
diff --git a/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.scss b/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.scss new file mode 100644 index 0000000000..eeaff6e83c --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.scss @@ -0,0 +1,71 @@ +/** + * 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. + */ +:host { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + gap: 16px; + .tb-embed-image-text { + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: rgba(0,0,0,0.54); + letter-spacing: 0.2px; + } + .tb-form-panel-title { + color: rgba(0, 0, 0, 0.87); + } + } +} + +:host ::ng-deep { + .tb-markdown-view { + max-width: 700px; + .tb-embed-image-code { + .code-wrapper { + padding: 0; + pre[class*=language-] { + margin: 0; + padding: 9px 38px 9px 16px; + } + code[class*="language-"], pre[class*="language-"] { + font-size: 12px; + overflow: hidden; + white-space: normal; + word-break: break-word; + } + button.clipboard-btn { + right: 0; + height: 36px; + p, div { + background: transparent; + } + p { + margin: 0; + padding: 6px; + font-size: 14px; + } + div { + top: 0; + padding: 8px; + height: 38px; + width: 38px; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts b/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts new file mode 100644 index 0000000000..f4d3e6d989 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts @@ -0,0 +1,94 @@ +/// +/// 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 { ImageResourceInfo } from '@shared/models/resource.models'; +import { Component, Inject, OnInit } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { ImageService } from '@core/http/image.service'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormControl, UntypedFormBuilder } from '@angular/forms'; + +export interface EmbedImageDialogData { + readonly: boolean; + image: ImageResourceInfo; +} + +@Component({ + selector: 'tb-embed-image-dialog', + templateUrl: './embed-image-dialog.component.html', + styleUrls: ['./embed-image-dialog.component.scss'] +}) +export class EmbedImageDialogComponent extends + DialogComponent implements OnInit { + + image = this.data.image; + + readonly = this.data.readonly; + + imageChanged = false; + + publicStatusControl = new FormControl(this.image.public); + + constructor(protected store: Store, + protected router: Router, + private imageService: ImageService, + @Inject(MAT_DIALOG_DATA) private data: EmbedImageDialogData, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + if (!this.readonly) { + this.publicStatusControl.valueChanges.subscribe( + (isPublic) => { + this.updateImagePublicStatus(isPublic); + } + ); + } + } + + cancel(): void { + this.dialogRef.close(this.imageChanged ? this.image : null); + } + + embedToHtmlCode(): string { + return '```html\n' + + ''+this.image.title.replace(/' + + '{:copy-code}\n' + + '```'; + } + + embedToAngularTemplateCode(): string { + return '```html\n' + + '' + + '{:copy-code}\n' + + '```'; + } + + private updateImagePublicStatus(isPublic: boolean): void { + this.imageService.updateImagePublicStatus(this.image, isPublic).subscribe( + (image) => { + this.image = image; + this.imageChanged = true; + } + ); + } + +} diff --git a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.html b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.html new file mode 100644 index 0000000000..d97035c048 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.html @@ -0,0 +1,94 @@ + +
+ +
+
+ +
+
+
+
+
+ +
+ {{ imageResource.title }} +
+
+
{{ imageResource.descriptor.width }}x{{ imageResource.descriptor.height }}
+ +
{{ imageResource.descriptor.size | fileSize }}
+
+
+
+
+ + +
+ +
+
+ + +
+
+
+ + +
{{ (disabled ? 'image.no-image' : 'image.no-image-selected') | translate }}
+
+ + + diff --git a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.scss b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.scss new file mode 100644 index 0000000000..d1c9e953a7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.scss @@ -0,0 +1,190 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../scss/constants"; + +$containerHeight: 96px !default; + +:host { + .tb-container { + margin-top: 0; + padding: 0 0 16px; + display: flex; + flex-direction: column; + gap: 8px; + label.tb-title { + display: block; + padding-bottom: 0; + } + } + + .tb-image-select-container { + width: 100%; + height: $containerHeight; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + &.disabled { + width: $containerHeight; + .tb-image-container { + width: $containerHeight - 2px; + border-right: none; + } + } + } + + .tb-image-container { + width: $containerHeight - 1px; + height: $containerHeight - 2px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + border-right: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; + overflow: hidden; + } + + .tb-image-preview { + width: auto; + max-width: $containerHeight - 2px; + height: auto; + max-height: $containerHeight - 2px; + } + + .tb-no-image { + text-align: center; + color: rgba(0, 0, 0, 0.38); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + } + + .tb-image-info-container { + display: flex; + flex: 1; + align-self: stretch; + padding: 0 8px; + justify-content: flex-end; + align-items: center; + gap: 4px; + .tb-base64-image-container, .tb-resource-image-container, .tb-external-image-container { + display: flex; + flex: 1; + align-self: stretch; + } + .tb-resource-image-container { + padding: 8px; + justify-content: center; + align-items: flex-start; + flex-direction: column; + gap: 4px; + .tb-resource-image-name { + color: rgba(0, 0, 0, 0.54); + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 2; + display: -webkit-box; + -webkit-box-orient: vertical; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.25px; + } + .tb-resource-image-details { + display: flex; + align-items: center; + gap: 8px; + color: rgba(0, 0, 0, 0.38); + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.017px; + .mat-divider.mat-divider-vertical { + height: 16px; + } + } + &.loading { + align-items: center; + } + } + + .tb-external-image-container { + padding: 16px 8px 0 16px; + flex-direction: column; + align-items: flex-start; + gap: 4px; + .tb-external-link-label { + color: rgba(0, 0, 0, 0.54); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + } + .tb-external-link-input-container { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 4px; + align-self: stretch; + .tb-inline-field { + width: 100%; + } + } + } + + .tb-image-clear-btn { + color: rgba(0,0,0,0.38); + } + } + + .tb-image-select-buttons-container { + display: flex; + flex: 1; + align-self: stretch; + padding: 8px; + gap: 8px; + justify-content: center; + align-items: flex-start; + .tb-image-select-button { + width: 100%; + height: 100%; + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + padding: 8px; + line-height: normal; + font-size: 12px; + @media #{$mat-gt-xs} { + padding: 16px; + } + .mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + margin: 0; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts new file mode 100644 index 0000000000..9b81a0eaba --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts @@ -0,0 +1,205 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { + extractParamsFromImageResourceUrl, + ImageResourceInfo, + isBase64DataImageUrl, + isImageResourceUrl, + prependTbImagePrefix, + removeTbImagePrefix +} from '@shared/models/resource.models'; +import { ImageService } from '@core/http/image.service'; +import { MatDialog } from '@angular/material/dialog'; +import { ImageGalleryDialogComponent } from '@shared/components/image/image-gallery-dialog.component'; + +export enum ImageLinkType { + none = 'none', + base64 = 'base64', + external = 'external', + resource = 'resource' +} + +@Component({ + selector: 'tb-gallery-image-input', + templateUrl: './gallery-image-input.component.html', + styleUrls: ['./gallery-image-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GalleryImageInputComponent), + multi: true + } + ] +}) +export class GalleryImageInputComponent extends PageComponent implements OnInit, OnDestroy, ControlValueAccessor { + + @Input() + label: string; + + @Input() + @coerceBoolean() + required = false; + + @Input() + disabled: boolean; + + imageUrl: string; + + imageResource: ImageResourceInfo; + + loadingImageResource = false; + + ImageLinkType = ImageLinkType; + + linkType: ImageLinkType = ImageLinkType.none; + + externalLinkControl = new FormControl(null); + + private propagateChange = null; + + constructor(protected store: Store, + private imageService: ImageService, + private dialog: MatDialog, + private cd: ChangeDetectorRef) { + super(store); + } + + ngOnInit() { + this.externalLinkControl.valueChanges.subscribe((value) => { + if (this.linkType === ImageLinkType.external) { + this.updateModel(value); + } + }); + } + + ngOnDestroy() { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.detectLinkType(); + this.externalLinkControl.disable({emitEvent: false}); + } else { + this.externalLinkControl.enable({emitEvent: false}); + } + } + + writeValue(value: string): void { + value = removeTbImagePrefix(value); + if (this.imageUrl !== value) { + this.reset(); + this.imageUrl = value; + this.detectLinkType(); + if (this.linkType === ImageLinkType.resource) { + const params = extractParamsFromImageResourceUrl(this.imageUrl); + this.loadingImageResource = true; + this.imageService.getImageInfo(params.type, params.key, {ignoreLoading: true, ignoreErrors: true}).subscribe( + { + next: (res) => { + this.imageResource = res; + this.loadingImageResource = false; + this.cd.markForCheck(); + }, + error: () => { + this.reset(); + this.loadingImageResource = false; + this.cd.markForCheck(); + } + } + ); + } else if (this.linkType === ImageLinkType.base64) { + this.cd.markForCheck(); + } else if (this.linkType === ImageLinkType.external) { + this.externalLinkControl.setValue(this.imageUrl, {emitEvent: false}); + this.cd.markForCheck(); + } + } + } + + private detectLinkType() { + if (this.imageUrl) { + if (isImageResourceUrl(this.imageUrl)) { + this.linkType = ImageLinkType.resource; + } else if (isBase64DataImageUrl(this.imageUrl)) { + this.linkType = ImageLinkType.base64; + } else { + this.linkType = ImageLinkType.external; + } + } else { + this.linkType = ImageLinkType.none; + } + } + + private updateModel(value: string) { + this.cd.markForCheck(); + if (this.imageUrl !== value) { + this.imageUrl = value; + this.propagateChange(prependTbImagePrefix(this.imageUrl)); + } + } + + private reset() { + this.linkType = ImageLinkType.none; + this.imageResource = null; + this.externalLinkControl.setValue(null, {emitEvent: false}); + } + + clearImage() { + this.reset(); + this.updateModel(null); + } + + setLink($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.linkType = ImageLinkType.external; + } + + openGallery($event: Event): void { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(ImageGalleryDialogComponent, { + autoFocus: false, + disableClose: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe((image) => { + if (image) { + this.linkType = ImageLinkType.resource; + this.imageResource = image; + this.updateModel(image.link); + } + }); + } + +} diff --git a/ui-ngx/src/app/shared/components/image/image-dialog.component.html b/ui-ngx/src/app/shared/components/image/image-dialog.component.html new file mode 100644 index 0000000000..dc2043097a --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.html @@ -0,0 +1,107 @@ + + + +

{{ (readonly ? 'image.image-details' : 'image.edit-image') | translate }}

+ + +
+ + +
+
+
+ + image.name + + + {{ 'image.name-required' | translate }} + + + + +
+
+
+
+ + + +
+ +
+
+
+ +
+
+
{{ image.descriptor.width }}x{{ image.descriptor.height }}
+ +
{{ image.descriptor.size | fileSize }}
+
+
+
+
+
+ diff --git a/ui-ngx/src/app/shared/components/image/image-dialog.component.scss b/ui-ngx/src/app/shared/components/image/image-dialog.component.scss new file mode 100644 index 0000000000..86ec3b2486 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.scss @@ -0,0 +1,96 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../scss/constants'; + +:host { + .tb-image-dialog { + @media #{$mat-gt-xs} { + width: 50vh; + } + .mat-mdc-dialog-content { + max-height: 100%; + } + fieldset { + height: 100%; + display: flex; + flex-direction: column; + } + } + .tb-image-container { + height: 100%; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.05); + padding: 12px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + + .tb-image-content { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + .tb-image-preview-container { + position: relative; + width: 100%; + height: 100%; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + .tb-image-preview-spacer { + @media #{$mat-gt-xs} { + margin-top: 100%; + } + } + .tb-image-preview { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + } + } + .tb-image-preview-details { + display: flex; + align-items: center; + gap: 8px; + color: rgba(0, 0, 0, 0.38); + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + .mat-divider.mat-divider-vertical { + height: 20px; + } + } + .tb-image-actions { + display: flex; + align-items: center; + align-self: stretch; + justify-content: space-between; + gap: 8px; + color: rgba(0,0,0,0.54); + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/image/image-dialog.component.ts b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts new file mode 100644 index 0000000000..434d76d74d --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts @@ -0,0 +1,163 @@ +/// +/// 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, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { ImageService } from '@core/http/image.service'; +import { ImageResourceInfo, imageResourceType } from '@shared/models/resource.models'; +import { + UploadImageDialogComponent, + UploadImageDialogData +} from '@shared/components/image/upload-image-dialog.component'; +import { UrlHolder } from '@shared/pipe/image.pipe'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EmbedImageDialogComponent, EmbedImageDialogData } from '@shared/components/image/embed-image-dialog.component'; + +export interface ImageDialogData { + readonly: boolean; + image: ImageResourceInfo; +} + +@Component({ + selector: 'tb-image-dialog', + templateUrl: './image-dialog.component.html', + styleUrls: ['./image-dialog.component.scss'] +}) +export class ImageDialogComponent extends + DialogComponent implements OnInit { + + image: ImageResourceInfo; + + readonly: boolean; + + imageFormGroup: UntypedFormGroup; + + imageChanged = false; + + imagePreviewData: UrlHolder; + + constructor(protected store: Store, + protected router: Router, + private imageService: ImageService, + private dialog: MatDialog, + private importExportService: ImportExportService, + @Inject(MAT_DIALOG_DATA) private data: ImageDialogData, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder) { + super(store, router, dialogRef); + this.image = data.image; + this.readonly = data.readonly; + this.imagePreviewData = { + url: this.image.public ? this.image.publicLink : this.image.link + }; + } + + ngOnInit(): void { + this.imageFormGroup = this.fb.group({ + title: [this.image.title, [Validators.required]] + }); + if (this.data.readonly) { + this.imageFormGroup.disable(); + } + } + + cancel(): void { + this.dialogRef.close(this.imageChanged ? this.image : null); + } + + revertInfo(): void { + this.imageFormGroup.get('title').setValue(this.image.title); + this.imageFormGroup.markAsPristine(); + } + + saveInfo(): void { + const title: string = this.imageFormGroup.get('title').value; + const image = {...this.image, ...{title}}; + this.imageService.updateImageInfo(image).subscribe( + (saved) => { + this.image = saved; + this.imageChanged = true; + this.imageFormGroup.markAsPristine(); + } + ); + } + + downloadImage($event) { + if ($event) { + $event.stopPropagation(); + } + this.imageService.downloadImage(imageResourceType(this.image), this.image.resourceKey).subscribe(); + } + + exportImage($event) { + if ($event) { + $event.stopPropagation(); + } + this.importExportService.exportImage(imageResourceType(this.image), this.image.resourceKey); + } + + embedImage($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(EmbedImageDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + image: this.image, + readonly: this.readonly + } + }).afterClosed().subscribe((result) => { + if (result) { + this.imageChanged = true; + this.image = result; + this.imagePreviewData = { + url: this.image.public ? this.image.publicLink : this.image.link + }; + } + }); + } + + updateImage($event): void { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(UploadImageDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + image: this.image + } + }).afterClosed().subscribe((result) => { + if (result) { + this.imageChanged = true; + this.image = result; + this.imagePreviewData = { + url: this.image.public ? this.image.publicLink : this.image.link + }; + } + }); + } + +} diff --git a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html new file mode 100644 index 0000000000..8e24e2ffb2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.html @@ -0,0 +1,32 @@ + + diff --git a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.scss b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.scss new file mode 100644 index 0000000000..6ebda09fb5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.scss @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../scss/constants'; + +:host { + .tb-image-gallery-dialog { + position: relative; + padding: 24px 32px 16px 32px; + width: 100vw; + height: 100vh; + max-height: 100vh; + @media #{$mat-gt-xs} { + width: 80vw; + height: 80vh; + max-height: 80vh; + } + @media #{$mat-gt-sm} { + width: 700px; + } + @media #{$mat-gt-md} { + width: 900px; + } + @media #{$mat-gt-xl} { + width: 900px; + } + .tb-image-gallery-close { + position: absolute; + top: 12px; + right: 12px; + z-index: 1; + color: rgba(0, 0, 0, 0.38); + } + } +} diff --git a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.ts b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.ts new file mode 100644 index 0000000000..482fbe76a5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.ts @@ -0,0 +1,62 @@ +/// +/// 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, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { ImageService } from '@core/http/image.service'; +import { ImageResourceInfo, imageResourceType } from '@shared/models/resource.models'; +import { + UploadImageDialogComponent, + UploadImageDialogData +} from '@shared/components/image/upload-image-dialog.component'; +import { UrlHolder } from '@shared/pipe/image.pipe'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EmbedImageDialogComponent, EmbedImageDialogData } from '@shared/components/image/embed-image-dialog.component'; + +@Component({ + selector: 'tb-image-gallery-dialog', + templateUrl: './image-gallery-dialog.component.html', + styleUrls: ['./image-gallery-dialog.component.scss'] +}) +export class ImageGalleryDialogComponent extends + DialogComponent implements OnInit { + + constructor(protected store: Store, + protected router: Router, + private imageService: ImageService, + private dialog: MatDialog, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + } + + cancel(): void { + this.dialogRef.close(null); + } + + imageSelected(image: ImageResourceInfo): void { + this.dialogRef.close(image); + } + +} diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.html b/ui-ngx/src/app/shared/components/image/image-gallery.component.html new file mode 100644 index 0000000000..21b10e8c66 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.html @@ -0,0 +1,397 @@ + +
+
+ +
+
+ image.gallery +
+
+ +
+
+ +
+
+ {{ 'image.include-system-images' | translate }} +
+
+ +
+ + + + +
+ +
+
+ {{ 'image.include-system-images' | translate }} +
+ +
+ + +   + + + +
+
+ +
+ + {{ translate.get('image.selected-images', {count: dataSource?.selection.selected.length}) | async }} + + + +
+
+
+
+ + + + + + + + + + + + + + +
+ {{ image.title }} +
+
+
+ + {{ 'image.name' | translate }} + + {{ image.title }} + + + + {{ 'image.created-time' | translate }} + + {{ image.createdTime | date:'yyyy-MM-dd HH:mm:ss' }} + + + + {{ 'image.resolution' | translate }} + + {{ image.descriptor.width }}x{{ image.descriptor.height }} + + + + {{ 'image.size' | translate }} + + {{ image.descriptor.size | fileSize }} + + + + {{ 'image.system' | translate }} + + {{isSystem(image) ? 'check_box' : 'check_box_outline_blank'}} + + + + + + +
+ + + + + +
+
+ + + + + + + + +
+
+
+ + + + + + + + +
+ + + + {{ 'common.loading' | translate }} +
+ + +
+
+ + +
+
+
+ +
+ + {{ 'common.loading' | translate }} + +
+
+ +
+
+
image.no-images
+
+
+ +
+
+
+ + + + +
+
+
+
+ + +
+
+ {{ item.title }} +
+
+
+
+ {{ item.title }} +
+
sys
+
+
+
{{ item.descriptor.width }}x{{ item.descriptor.height }}
+ +
{{ item.descriptor.size | fileSize }}
+
+
+
+
+ +
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.scss b/ui-ngx/src/app/shared/components/image/image-gallery.component.scss new file mode 100644 index 0000000000..cd81199053 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.scss @@ -0,0 +1,274 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../scss/constants"; + +$tb-button-selected-color: rgb(255, 110, 64) !default; + +.tb-images { + + &.tb-dialog-mode { + position: relative; + width: 100%; + height: 100%; + .mat-toolbar.mat-mdc-table-toolbar { + padding: 0; + } + } + + .tb-images-content { + width: 100%; + height: 100%; + background: #fff; + overflow: hidden; + + &.tb-outlined-border { + box-shadow: 0 0 0 0 rgb(0 0 0 / 20%), 0 0 0 0 rgb(0 0 0 / 14%), 0 0 0 0 rgb(0 0 0 / 12%); + border: solid 1px #e0e0e0; + border-radius: 4px; + } + + .mat-mdc-table-toolbar { + &.multi-row { + &.mat-toolbar-single-row { + height: 112px; + } + .mat-mdc-slide-toggle { + display: flex; + min-height: 48px; + } + } + } + + .tb-images-title { + padding-right: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .tb-images-view-type-toolbar { + height: 55px; + min-height: 55px; + padding-right: 16px; + background-color: transparent; + box-shadow: none; + + .tb-toolbar-button { + height: 48px; + button.mat-mdc-icon-button { + margin: 0; + } + &.tb-selected { + background-color: rgba(255, 255, 255, .15); + border-bottom: $tb-button-selected-color solid 4px; + + button.mat-mdc-icon-button { + margin-bottom: -4px; + + .mat-icon { + color: $tb-button-selected-color; + fill: $tb-button-selected-color; + } + } + } + } + } + + .table-container, tb-scroll-grid { + position: relative; + } + + .tb-no-images { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + .tb-image-card { + position: relative; + height: 100%; + border-radius: 4px; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.24); + padding: 8px; + display: flex; + gap: 8px; + flex-direction: column; + cursor: pointer; + + .tb-image-card-overlay { + position: absolute; + pointer-events: none; + inset: 0; + border-radius: 4px; + z-index: 2; + display: flex; + flex-direction: column; + .tb-image-card-overlay-buttons { + width: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + color: rgba(0,0,0,0.78); + opacity: 0; + transition: opacity 0.5s; + } + } + + &:hover { + .tb-image-card-overlay { + .tb-image-card-overlay-buttons { + opacity: 1; + } + } + .tb-image-preview-container { + .tb-image-preview-overlay { + background: rgba(245,245,245,0.6); + backdrop-filter: blur(4px); + .mdc-button { + opacity: 1; + } + } + } + } + + .tb-image-preview-container { + position: relative; + .tb-image-preview-overlay { + position: absolute; + inset: 0; + z-index: 1; + background: rgba(245,245,245,0); + backdrop-filter: none; + transition: all 0.5s; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + .mdc-button { + opacity: 0; + transition: opacity 0.5s; + } + } + .tb-image-preview-spacer { + margin-top: 100%; + } + .tb-image-preview { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + } + + .tb-image-details { + min-height: 52px; + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 4px; + align-self: stretch; + .tb-image-title-container { + display: flex; + align-items: center; + gap: 8px; + align-self: stretch; + .tb-image-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 2; + display: -webkit-box; + -webkit-box-orient: vertical; + color: rgba(0, 0, 0, 0.76); + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + } + .tb-image-sys { + padding: 1px 4px; + border-radius: 4px; + background: rgba(236, 236, 236, 0.64); + color: rgba(0, 0, 0, 0.54); + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.017px; + } + } + .tb-image-info-container { + display: flex; + align-items: center; + gap: 8px; + align-self: stretch; + color: rgba(0, 0, 0, 0.38); + font-size: 10px; + font-style: normal; + font-weight: 400; + line-height: 16px; + .mat-divider.mat-divider-vertical { + height: 16px; + } + } + } + &.loading-cell { + .tb-image-preview-container, .tb-image-details { + background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%); + border-radius: 5px; + background-size: 200% 100%; + animation: 1s shine linear infinite; + } + } + } + + .table-container { + overflow: auto; + + .mat-sort-header-sorted .mat-sort-header-arrow { + opacity: 1 !important; + } + .mat-mdc-cell.mat-column-preview { + width: 50px; + height: 50px; + padding: 2px 12px; + } + } + + .tb-image-preview-cell { + width: 50px; + height: 50px; + } + + .tb-image-preview { + width: 50px; + height: 50px; + object-fit: contain; + border-radius: 4px; + } + } +} + +@keyframes shine { + to { + background-position-x: -200%; + } +} diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.ts b/ui-ngx/src/app/shared/components/image/image-gallery.component.ts new file mode 100644 index 0000000000..d0451cebcd --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.ts @@ -0,0 +1,702 @@ +/// +/// 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 { + ImageResourceInfo, + ImageResourceInfoWithReferences, + imageResourceType, + toImageDeleteResult +} from '@shared/models/resource.models'; +import { forkJoin, merge, Observable, of, Subject, Subscription } from 'rxjs'; +import { ImageService } from '@core/http/image.service'; +import { TranslateService } from '@ngx-translate/core'; +import { PageLink, PageQueryParam } from '@shared/models/page/page-link'; +import { catchError, debounceTime, distinctUntilChanged, map, skip, takeUntil } from 'rxjs/operators'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, EventEmitter, HostBinding, + Input, + OnDestroy, + OnInit, Output, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, SortDirection } from '@angular/material/sort'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogService } from '@core/services/dialog.service'; +import { FormBuilder } from '@angular/forms'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { hidePageSizePixelValue } from '@shared/models/constants'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { ActivatedRoute, QueryParamsHandling, Router } from '@angular/router'; +import { isEqual, isNotEmptyStr, parseHttpErrorMessage } from '@core/utils'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { GridEntitiesFetchFunction, ScrollGridColumns } from '@shared/components/grid/scroll-grid-datasource'; +import { ItemSizeStrategy, ScrollGridComponent } from '@shared/components/grid/scroll-grid.component'; +import { MatDialog } from '@angular/material/dialog'; +import { + UploadImageDialogComponent, + UploadImageDialogData +} from '@shared/components/image/upload-image-dialog.component'; +import { ImageDialogComponent, ImageDialogData } from '@shared/components/image/image-dialog.component'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { + ImagesInUseDialogComponent, + ImagesInUseDialogData +} from '@shared/components/image/images-in-use-dialog.component'; +import { ImagesDatasource } from '@shared/components/image/images-datasource'; +import { EmbedImageDialogComponent, EmbedImageDialogData } from '@shared/components/image/embed-image-dialog.component'; + +interface GridImagesFilter { + search: string; + includeSystemImages: boolean; +} + +const pageGridColumns: ScrollGridColumns = { + columns: 2, + breakpoints: { + 'screen and (min-width: 2320px)': 10, + 'screen and (min-width: 2000px)': 8, + 'gt-lg': 7, + 'screen and (min-width: 1600px)': 6, + 'gt-md': 5, + 'screen and (min-width: 1120px)': 4, + 'gt-xs': 3 + } +}; + +const dialogGridColumns: ScrollGridColumns = { + columns: 2, + breakpoints: { + 'gt-md': 4, + 'gt-xs': 3 + } +}; + +@Component({ + selector: 'tb-image-gallery', + templateUrl: './image-gallery.component.html', + styleUrls: ['./image-gallery.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ImageGalleryComponent extends PageComponent implements OnInit, OnDestroy, AfterViewInit { + + @HostBinding('style.display') + private display = 'block'; + + @HostBinding('style.width') + private width = '100%'; + + @HostBinding('style.height') + private height = '100%'; + + @Input() + @coerceBoolean() + pageMode = true; + + @Input() + @coerceBoolean() + dialogMode = false; + + @Input() + mode: 'list' | 'grid' = 'list'; + + @Input() + @coerceBoolean() + selectionMode = false; + + @Output() + imageSelected = new EventEmitter(); + + @ViewChild('searchInput') searchInputField: ElementRef; + + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + @ViewChild(ScrollGridComponent) gridComponent: ScrollGridComponent; + + defaultPageSize = 10; + defaultSortOrder: SortOrder = { property: 'createdTime', direction: Direction.DESC }; + hidePageSize = false; + + displayedColumns: string[]; + pageSizeOptions: number[]; + pageLink: PageLink; + + textSearchMode = false; + + dataSource: ImagesDatasource; + + textSearch = this.fb.control('', {nonNullable: true}); + includeSystemImages = false; + + gridColumns: ScrollGridColumns; + + gridImagesFetchFunction: GridEntitiesFetchFunction; + gridImagesFilter: GridImagesFilter = { + search: '', + includeSystemImages: false + }; + + gridImagesItemSizeStrategy: ItemSizeStrategy = { + defaultItemSize: 200, + itemSizeFunction: itemWidth => itemWidth + 72 + }; + + authUser = getCurrentAuthUser(this.store); + + private updateDataSubscription: Subscription; + + private widgetResize$: ResizeObserver; + private destroy$ = new Subject(); + private destroyListMode$: Subject; + + constructor(protected store: Store, + private route: ActivatedRoute, + private router: Router, + private dialog: MatDialog, + public translate: TranslateService, + private imageService: ImageService, + private dialogService: DialogService, + private importExportService: ImportExportService, + private elementRef: ElementRef, + private cd: ChangeDetectorRef, + private fb: FormBuilder) { + super(store); + + this.gridImagesFetchFunction = (pageSize, page, filter) => { + const pageLink = new PageLink(pageSize, page, filter.search, { + property: 'createdTime', + direction: Direction.DESC + }); + return this.imageService.getImages(pageLink, filter.includeSystemImages); + }; + } + + ngOnInit(): void { + this.gridColumns = this.dialogMode ? dialogGridColumns : pageGridColumns; + this.displayedColumns = this.computeDisplayedColumns(); + let sortOrder: SortOrder = this.defaultSortOrder; + this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + const routerQueryParams: PageQueryParam = this.route.snapshot.queryParams; + if (this.pageMode) { + if (routerQueryParams.hasOwnProperty('direction') + || routerQueryParams.hasOwnProperty('property')) { + sortOrder = { + property: routerQueryParams?.property || this.defaultSortOrder.property, + direction: routerQueryParams?.direction || this.defaultSortOrder.direction + }; + } + } + this.pageLink = new PageLink(this.defaultPageSize, 0, null, sortOrder); + if (this.pageMode) { + if (routerQueryParams.hasOwnProperty('page')) { + this.pageLink.page = Number(routerQueryParams.page); + } + if (routerQueryParams.hasOwnProperty('pageSize')) { + this.pageLink.pageSize = Number(routerQueryParams.pageSize); + } + const textSearchParam = routerQueryParams.textSearch; + if (isNotEmptyStr(textSearchParam)) { + const decodedTextSearch = decodeURI(textSearchParam); + this.textSearchMode = true; + this.pageLink.textSearch = decodedTextSearch.trim(); + this.textSearch.setValue(decodedTextSearch, {emitEvent: false}); + } + } + if (this.mode === 'list') { + this.dataSource = new ImagesDatasource(this.imageService, null, + entity => this.deleteEnabled(entity)); + } + } + + ngOnDestroy(): void { + if (this.widgetResize$) { + this.widgetResize$.disconnect(); + } + if (this.destroyListMode$) { + this.destroyListMode$.next(); + this.destroyListMode$.complete(); + } + this.destroy$.next(); + this.destroy$.complete(); + } + + ngAfterViewInit() { + this.textSearch.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged((prev, current) => + ((this.mode === 'list' ? this.pageLink.textSearch : this.gridImagesFilter.search) ?? '') === current.trim()), + takeUntil(this.destroy$) + ).subscribe(value => { + if (this.mode === 'list') { + if (this.pageMode) { + const queryParams: PageQueryParam = { + textSearch: isNotEmptyStr(value) ? encodeURI(value) : null, + page: null + }; + this.updatedRouterParamsAndData(queryParams); + } else { + this.pageLink.textSearch = isNotEmptyStr(value) ? value.trim() : null; + this.paginator.pageIndex = 0; + this.updateData(); + } + } else { + this.gridImagesFilter = { + search: isNotEmptyStr(value) ? value.trim() : null, + includeSystemImages: this.includeSystemImages + }; + this.cd.markForCheck(); + } + }); + this.updateMode(); + } + + public includeSystemImagesChanged(value: boolean) { + this.includeSystemImages = value; + this.displayedColumns = this.computeDisplayedColumns(); + this.gridImagesFilter = { + search: this.gridImagesFilter.search, + includeSystemImages: this.includeSystemImages + }; + if (this.mode === 'list') { + this.paginator.pageIndex = 0; + this.updateData(); + } else { + this.cd.markForCheck(); + } + } + + public setMode(targetMode: 'list' | 'grid') { + if (this.mode !== targetMode) { + if (this.widgetResize$) { + this.widgetResize$.disconnect(); + this.widgetResize$ = null; + } + if (this.destroyListMode$) { + this.destroyListMode$.next(); + this.destroyListMode$.complete(); + this.destroyListMode$ = null; + } + this.mode = targetMode; + if (this.mode === 'list') { + this.dataSource = new ImagesDatasource(this.imageService, null, + entity => this.deleteEnabled(entity)); + } + setTimeout(() => { + this.updateMode(); + }); + } + } + + public get isSysAdmin(): boolean { + return this.authUser.authority === Authority.SYS_ADMIN; + } + + private computeDisplayedColumns(): string[] { + let columns: string[]; + if (this.selectionMode) { + columns = ['preview', 'title']; + if (!this.isSysAdmin && this.includeSystemImages) { + columns.push('system'); + } + columns.push('imageSelect'); + } else { + columns = ['select', 'preview', 'title', 'createdTime', 'resolution', 'size']; + if (!this.isSysAdmin && this.includeSystemImages) { + columns.push('system'); + } + columns.push('actions'); + } + return columns; + } + + private updateMode() { + if (this.mode === 'list') { + this.initListMode(); + } else { + this.initGridMode(); + } + } + + private initListMode() { + this.destroyListMode$ = new Subject(); + this.widgetResize$ = new ResizeObserver(() => { + const showHidePageSize = this.elementRef.nativeElement.offsetWidth < hidePageSizePixelValue; + if (showHidePageSize !== this.hidePageSize) { + this.hidePageSize = showHidePageSize; + this.cd.markForCheck(); + } + }); + this.widgetResize$.observe(this.elementRef.nativeElement); + if (this.pageMode) { + this.route.queryParams.pipe( + skip(1), + takeUntil(this.destroyListMode$) + ).subscribe((params: PageQueryParam) => { + this.paginator.pageIndex = Number(params.page) || 0; + this.paginator.pageSize = Number(params.pageSize) || this.defaultPageSize; + this.sort.active = params.property || this.defaultSortOrder.property; + this.sort.direction = (params.direction || this.defaultSortOrder.direction).toLowerCase() as SortDirection; + const textSearchParam = params.textSearch; + if (isNotEmptyStr(textSearchParam)) { + const decodedTextSearch = decodeURI(textSearchParam); + this.textSearchMode = true; + this.pageLink.textSearch = decodedTextSearch.trim(); + this.textSearch.setValue(decodedTextSearch, {emitEvent: false}); + } else { + this.pageLink.textSearch = null; + this.textSearch.reset('', {emitEvent: false}); + } + this.updateData(); + }); + } + this.updatePaginationSubscriptions(); + this.updateData(); + } + + private initGridMode() { + + } + + private updatePaginationSubscriptions() { + if (this.updateDataSubscription) { + this.updateDataSubscription.unsubscribe(); + this.updateDataSubscription = null; + } + const sortSubscription$: Observable = this.sort.sortChange.asObservable().pipe( + map((data) => { + const direction = data.direction.toUpperCase(); + const queryParams: PageQueryParam = { + direction: (this.defaultSortOrder.direction === direction ? null : direction) as Direction, + property: this.defaultSortOrder.property === data.active ? null : data.active + }; + queryParams.page = null; + this.paginator.pageIndex = 0; + return queryParams; + }) + ); + const paginatorSubscription$ = this.paginator.page.asObservable().pipe( + map((data) => ({ + page: data.pageIndex === 0 ? null : data.pageIndex, + pageSize: data.pageSize === this.defaultPageSize ? null : data.pageSize + })) + ); + this.updateDataSubscription = (merge(sortSubscription$, paginatorSubscription$) as Observable).pipe( + takeUntil(this.destroyListMode$) + ).subscribe(queryParams => this.updatedRouterParamsAndData(queryParams)); + } + + clearSelection() { + this.dataSource.selection.clear(); + this.cd.detectChanges(); + } + + updateData() { + if (this.mode === 'list') { + this.pageLink.page = this.paginator.pageIndex; + this.pageLink.pageSize = this.paginator.pageSize; + if (this.sort.active) { + this.pageLink.sortOrder = { + property: this.sort.active, + direction: Direction[this.sort.direction.toUpperCase()] + }; + } else { + this.pageLink.sortOrder = null; + } + this.dataSource.loadEntities(this.pageLink, this.includeSystemImages); + } else { + this.gridComponent.update(); + } + } + + private imageUpdated(image: ImageResourceInfo, index = -1) { + if (this.mode === 'list') { + this.updateData(); + } else { + this.gridComponent.updateItem(index, image); + } + } + + private imageDeleted(index = -1) { + if (this.mode === 'list') { + this.updateData(); + } else { + this.gridComponent.deleteItem(index); + } + } + + enterFilterMode() { + this.textSearchMode = true; + setTimeout(() => { + this.searchInputField.nativeElement.focus(); + this.searchInputField.nativeElement.setSelectionRange(0, 0); + }, 10); + } + + exitFilterMode() { + this.textSearchMode = false; + this.textSearch.reset(); + } + + trackByEntity(index: number, entity: BaseData) { + return entity; + } + + isSystem(image?: ImageResourceInfo): boolean { + return !this.isSysAdmin && image?.tenantId?.id === NULL_UUID; + } + + readonly(image?: ImageResourceInfo): boolean { + return this.authUser.authority !== Authority.SYS_ADMIN && this.isSystem(image); + } + + deleteEnabled(image?: ImageResourceInfo): boolean { + return this.authUser.authority === Authority.SYS_ADMIN || !this.isSystem(image); + } + + deleteImage($event: Event, image: ImageResourceInfo, itemIndex = -1) { + if ($event) { + $event.stopPropagation(); + } + const title = this.translate.instant('image.delete-image-title', {imageTitle: image.title}); + const content = this.translate.instant('image.delete-image-text'); + this.dialogService.confirm(title, content, + this.translate.instant('action.no'), + this.translate.instant('action.yes')).subscribe((result) => { + if (result) { + this.imageService.deleteImage(imageResourceType(image), image.resourceKey, false, {ignoreErrors: true}).pipe( + map(() => toImageDeleteResult(image)), + catchError((err) => of(toImageDeleteResult(image, err))) + ).subscribe( + (deleteResult) => { + if (deleteResult.success) { + this.imageDeleted(itemIndex); + } else if (deleteResult.imageIsReferencedError) { + this.dialog.open(ImagesInUseDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + multiple: false, + images: [{...image, ...{references: deleteResult.references}}] + } + }).afterClosed().subscribe((images) => { + if (images) { + this.imageService.deleteImage(imageResourceType(image), image.resourceKey, true).subscribe( + () => { + this.imageDeleted(itemIndex); + } + ); + } + }); + } else { + const errorMessageWithTimeout = parseHttpErrorMessage(deleteResult.error, this.translate); + setTimeout(() => { + this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'})); + }, errorMessageWithTimeout.timeout); + } + }); + } + }); + } + + deleteImages($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const selectedImages = this.dataSource.selection.selected; + if (selectedImages && selectedImages.length) { + const title = this.translate.instant('image.delete-images-title', {count: selectedImages.length}); + const content = this.translate.instant('image.delete-images-text'); + this.dialogService.confirm(title, content, + this.translate.instant('action.no'), + this.translate.instant('action.yes')).subscribe((result) => { + if (result) { + const tasks = selectedImages.map((image) => + this.imageService.deleteImage(imageResourceType(image), image.resourceKey, false, {ignoreErrors: true}).pipe( + map(() => toImageDeleteResult(image)), + catchError((err) => of(toImageDeleteResult(image, err))) + ) + ); + forkJoin(tasks).subscribe( + (deleteResults) => { + const anySuccess = deleteResults.some(res => res.success); + const referenceErrors = deleteResults.filter(res => res.imageIsReferencedError); + const otherError = deleteResults.find(res => !res.success); + if (anySuccess) { + this.updateData(); + } + if (referenceErrors?.length) { + const imagesWithReferences: ImageResourceInfoWithReferences[] = + referenceErrors.map(ref => ({...ref.image, ...{references: ref.references}})); + this.dialog.open(ImagesInUseDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + multiple: true, + images: imagesWithReferences + } + }).afterClosed().subscribe((forceDeleteImages) => { + if (forceDeleteImages && forceDeleteImages.length) { + const forceDeleteTasks = forceDeleteImages.map((image) => + this.imageService.deleteImage(imageResourceType(image), image.resourceKey, true) + ); + forkJoin(forceDeleteTasks).subscribe( + () => { + this.updateData(); + } + ); + } + }); + } else if (otherError) { + const errorMessageWithTimeout = parseHttpErrorMessage(otherError.error, this.translate); + setTimeout(() => { + this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'})); + }, errorMessageWithTimeout.timeout); + } + } + ); + } + }); + } + } + + downloadImage($event, image: ImageResourceInfo) { + if ($event) { + $event.stopPropagation(); + } + this.imageService.downloadImage(imageResourceType(image), image.resourceKey).subscribe(); + } + + exportImage($event, image: ImageResourceInfo) { + if ($event) { + $event.stopPropagation(); + } + this.importExportService.exportImage(imageResourceType(image), image.resourceKey); + } + + importImage(): void { + this.importExportService.importImage().subscribe((image) => { + if (image) { + if (this.selectionMode) { + this.imageSelected.next(image); + } else { + this.updateData(); + } + } + }); + } + + selectImage($event, image: ImageResourceInfo) { + if ($event) { + $event.stopPropagation(); + } + this.imageSelected.next(image); + } + + rowClick($event, image: ImageResourceInfo) { + if (this.selectionMode) { + this.selectImage($event, image); + } else { + if (this.deleteEnabled(image)) { + this.dataSource.selection.toggle(image); + } + } + } + + uploadImage(): void { + this.dialog.open(UploadImageDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: {} + }).afterClosed().subscribe((result) => { + if (result) { + if (this.selectionMode) { + this.imageSelected.next(result); + } else { + this.updateData(); + } + } + }); + } + + editImage($event: Event, image: ImageResourceInfo, itemIndex = -1) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(ImageDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + image, + readonly: this.readonly(image) + } + }).afterClosed().subscribe((result) => { + if (result) { + this.imageUpdated(result, itemIndex); + } + }); + } + + embedImage($event: Event, image: ImageResourceInfo, itemIndex = -1) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(EmbedImageDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + image, + readonly: this.readonly(image) + } + }).afterClosed().subscribe((result) => { + if (result) { + this.imageUpdated(result, itemIndex); + } + }); + } + + protected updatedRouterParamsAndData(queryParams: object, queryParamsHandling: QueryParamsHandling = 'merge') { + if (this.pageMode) { + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling + }); + if (queryParamsHandling === '' && isEqual(this.route.snapshot.queryParams, queryParams)) { + this.updateData(); + } + } else { + this.updateData(); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/image/image-references.component.html b/ui-ngx/src/app/shared/components/image/image-references.component.html new file mode 100644 index 0000000000..a9b6943bea --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-references.component.html @@ -0,0 +1,54 @@ + + + + + + +
    + +
  • +
    {{ 'image.system-entities' | translate }}
    + +
  • + +
  • +
    + {{ 'tenant.tenant' | translate }} {{ entry[1].tenantName }} {{ 'image.entities' | translate }} +
    + +
  • +
    +
    +
+
+
+ + + + + + +
{{ referencedEntity.typeName }} + {{ referencedEntity.entity.name }} + {{ referencedEntity.entity.name }} +
+
+ + + diff --git a/ui-ngx/src/app/shared/components/image/image-references.component.scss b/ui-ngx/src/app/shared/components/image/image-references.component.scss new file mode 100644 index 0000000000..bef9cc61ea --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-references.component.scss @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../scss/constants"; + +:host { + ul.tb-references { + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; + } + li.tb-entities-container { + padding: 8px 12px 12px 12px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + border-radius: 8px; + } + a.tb-reference { + color: $tb-primary-color; + font-weight: inherit; + } + .tb-entities-title { + display: flex; + gap: 8px; + color: rgba(0, 0, 0, 0.87); + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + } + table.tb-entities-list-table { + position: relative; + overflow: hidden; + padding: 8px 10px 8px 4px; + border-radius: 4px; + background: #fff; + align-self: stretch; + z-index: 1; + color: rgba(0, 0, 0, 0.76); + &:before { + display: block; + height: auto; + content: ""; + position: absolute; + inset: 0; + border-radius: 4px; + border: 1px solid $tb-primary-color; + background: transparent; + opacity: 0.4; + pointer-events: none; + } + td.tb-entity-type { + white-space: nowrap; + padding-right: 20px; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + &:before { + content: "•"; + padding-left: 8px; + padding-right: 8px; + } + } + td.tb-entity-name { + width: 100%; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/image/image-references.component.ts b/ui-ngx/src/app/shared/components/image/image-references.component.ts new file mode 100644 index 0000000000..e7ae17d50d --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-references.component.ts @@ -0,0 +1,171 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ImageReferences } from '@shared/models/resource.models'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { getEntityDetailsPageURL } from '@core/utils'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { Authority } from '@shared/models/authority.enum'; +import { Observable } from 'rxjs'; +import { EntityService } from '@core/http/entity.service'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { HasTenantId } from '@shared/models/entity.models'; +import { map } from 'rxjs/operators'; +import { TbPopoverComponent } from '@shared/components/popover.component'; + +interface ReferencedEntityInfo { + entity: BaseData & HasTenantId; + typeName: string; + detailsUrl: string; +} + +interface TenantReferencedEntities { + tenantName?: string; + tenantDetailsUrl?: string; + entities: ReferencedEntityInfo[]; +} + +type ReferencedEntities = {[tenantId: string]: TenantReferencedEntities}; +type ReferencedEntitiesEntry = [string, TenantReferencedEntities]; + +@Component({ + selector: 'tb-image-references', + templateUrl: './image-references.component.html', + styleUrls: ['./image-references.component.scss'] +}) +export class ImageReferencesComponent implements OnInit { + + @Input() + references: ImageReferences; + + popoverComponent: TbPopoverComponent; + + contentReady = false; + + authUser = getCurrentAuthUser(this.store); + + simpleList = true; + + referencedEntitiesList: ReferencedEntityInfo[]; + + referencedEntitiesEntries: ReferencedEntitiesEntry[]; + + constructor(protected store: Store, + private entityService: EntityService, + private cd: ChangeDetectorRef, + private translate: TranslateService) { + } + + ngOnInit(): void { + if (this.authUser.authority === Authority.SYS_ADMIN && this.hasNonSystemEntities(this.references)) { + this.simpleList = false; + this.toReferencedEntitiesEntries(this.references).subscribe( + (entries) => { + this.referencedEntitiesEntries = entries; + this.contentReady = true; + this.cd.detectChanges(); + if (this.popoverComponent) { + Promise.resolve().then(() => { + this.popoverComponent.updatePosition(); + }); + } + } + ); + } else { + this.referencedEntitiesList = this.toReferencedEntitiesList(this.references); + this.contentReady = true; + } + } + + isSystem(tenantId: string): boolean { + return tenantId === NULL_UUID; + } + + private hasNonSystemEntities(references: ImageReferences): boolean { + for (const entityTypeStr of Object.keys(references)) { + const entities = this.references[entityTypeStr]; + if (entities.some(e => e.tenantId && e.tenantId.id && e.tenantId.id !== NULL_UUID)) { + return true; + } + } + return false; + } + + private toReferencedEntitiesList(references: ImageReferences): ReferencedEntityInfo[] { + const result: ReferencedEntityInfo[] = []; + for (const entityTypeStr of Object.keys(references)) { + const entityType = entityTypeStr as EntityType; + const entityTypeName = this.translate.instant(entityTypeTranslations.get(entityType).type); + const entities = references[entityTypeStr]; + for (const entity of entities) { + const detailsUrl = getEntityDetailsPageURL(entity.id.id, entityType); + result.push({ + entity, + typeName: entityTypeName, + detailsUrl + }); + } + } + return result; + } + + private toReferencedEntitiesEntries(references: ImageReferences): Observable { + let referencedEntities: ReferencedEntities = {}; + const referencedEntitiesList = this.toReferencedEntitiesList(references); + for (const referencedEntityInfo of referencedEntitiesList) { + const tenantId = referencedEntityInfo.entity.tenantId?.id || NULL_UUID; + let tenantEntitiesInfo = referencedEntities[tenantId]; + if (!tenantEntitiesInfo) { + tenantEntitiesInfo = { + entities: [] + }; + referencedEntities[tenantId] = tenantEntitiesInfo; + } + tenantEntitiesInfo.entities.push(referencedEntityInfo); + } + referencedEntities = Object.keys(referencedEntities).sort((tenantId1, tenantId2) => { + if (tenantId1 === NULL_UUID) { + return -1; + } else if (tenantId2 === NULL_UUID) { + return 1; + } + return 0; + }).reduce( + (obj, key) => { + obj[key] = referencedEntities[key]; + return obj; + }, + {} + ); + const tenantIds = Object.keys(referencedEntities).filter(id => id !== NULL_UUID); + return this.entityService.getEntities(EntityType.TENANT, tenantIds).pipe( + map((tenants) => { + for (const tenant of tenants) { + const tenantEntitiesInfo = referencedEntities[tenant.id.id]; + tenantEntitiesInfo.tenantName = tenant.name; + tenantEntitiesInfo.tenantDetailsUrl = getEntityDetailsPageURL(tenant.id.id, EntityType.TENANT); + } + return Object.entries(referencedEntities); + }) + ); + } + +} diff --git a/ui-ngx/src/app/shared/components/image/images-datasource.ts b/ui-ngx/src/app/shared/components/image/images-datasource.ts new file mode 100644 index 0000000000..fd92cae367 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/images-datasource.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 { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections'; +import { ImageResourceInfo } from '@shared/models/resource.models'; +import { BehaviorSubject, Observable, of, ReplaySubject, Subject } from 'rxjs'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { ImageService } from '@core/http/image.service'; +import { EntityBooleanFunction } from '@home/models/entity/entities-table-config.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { catchError, map, take, tap } from 'rxjs/operators'; + +export class ImagesDatasource implements DataSource { + private entitiesSubject: Subject; + private readonly pageDataSubject: Subject>; + + public pageData$: Observable>; + + public selection = new SelectionModel(true, []); + + public dataLoading = true; + + constructor(private imageService: ImageService, + private images: ImageResourceInfo[], + private selectionEnabledFunction: EntityBooleanFunction) { + if (this.images && this.images.length) { + this.entitiesSubject = new BehaviorSubject(this.images); + } else { + this.entitiesSubject = new BehaviorSubject([]); + this.pageDataSubject = new BehaviorSubject>(emptyPageData()); + this.pageData$ = this.pageDataSubject.asObservable(); + } + } + + connect(collectionViewer: CollectionViewer): + Observable> { + return this.entitiesSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.entitiesSubject.complete(); + if (this.pageDataSubject) { + this.pageDataSubject.complete(); + } + } + + reset() { + this.entitiesSubject.next([]); + if (this.pageDataSubject) { + this.pageDataSubject.next(emptyPageData()); + } + } + + loadEntities(pageLink: PageLink, includeSystemImages = false): Observable> { + this.dataLoading = true; + const result = new ReplaySubject>(); + this.fetchEntities(pageLink, includeSystemImages).pipe( + tap(() => { + this.selection.clear(); + }), + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.entitiesSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + result.next(pageData); + this.dataLoading = false; + } + ); + return result; + } + + fetchEntities(pageLink: PageLink, includeSystemImages = false): Observable> { + return this.imageService.getImages(pageLink, includeSystemImages); + } + + isAllSelected(): Observable { + const numSelected = this.selection.selected.length; + return this.entitiesSubject.pipe( + map((entities) => numSelected === entities.length) + ); + } + + isEmpty(): Observable { + return this.entitiesSubject.pipe( + map((entities) => !entities.length) + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements) + ); + } + + masterToggle() { + this.entitiesSubject.pipe( + tap((entities) => { + const numSelected = this.selection.selected.length; + if (numSelected === this.selectableEntitiesCount(entities)) { + this.selection.clear(); + } else { + entities.forEach(row => { + if (this.selectionEnabledFunction(row)) { + this.selection.select(row); + } + }); + } + }), + take(1) + ).subscribe(); + } + + private selectableEntitiesCount(entities: Array): number { + return entities.filter((entity) => this.selectionEnabledFunction(entity)).length; + } +} diff --git a/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.html b/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.html new file mode 100644 index 0000000000..6bbc62ade2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.html @@ -0,0 +1,82 @@ + +

{{title}}

+
+
+ +
+ + + + + + + + + + + + + + + {{ image.title }} + + + + + + {{ 'image.name' | translate }} + + + {{ translate.get('image.selected-images', {count: dataSource.selection.selected.length}) | async }} + + + + {{ image.title }} + + + + + + + + + + +
+
+
+
+
+ + +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.scss b/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss similarity index 55% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.scss rename to ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss index 8b61fb7e3d..682f3a1d0c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.scss +++ b/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. @@ -14,27 +14,27 @@ * limitations under the License. */ :host { - display: block; - .mat-expansion-panel { - box-shadow: none; - &.gauge-highlight { - border: 1px groove rgba(0, 0, 0, .25); - .mat-expansion-panel-header { - padding: 0 24px 0 8px; - &.mat-expanded { - height: 48px; - } - } + .tb-images-in-use-content { + display: flex; + flex-direction: column; + gap: 24px; + &.multiple { + gap: 16px; } - } -} - -:host ::ng-deep { - .mat-expansion-panel { - &.gauge-highlight { - .mat-expansion-panel-body { - padding: 0 8px 8px; + .table-container { + overflow: auto; + .mat-mdc-cell.mat-column-preview { + width: 50px; + height: 50px; + padding: 2px 12px; + display: block; } } + .tb-image-preview { + width: 50px; + height: 50px; + object-fit: contain; + border-radius: 4px; + } } } diff --git a/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.ts b/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.ts new file mode 100644 index 0000000000..20b551c872 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.ts @@ -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. +/// + +import { Component, Inject, OnInit, Renderer2, ViewContainerRef } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { ImageReferences, ImageResourceInfo, ImageResourceInfoWithReferences } from '@shared/models/resource.models'; +import { ImagesDatasource } from '@shared/components/image/images-datasource'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { ImageReferencesComponent } from '@shared/components/image/image-references.component'; +import { TranslateService } from '@ngx-translate/core'; + +export interface ImagesInUseDialogData { + multiple: boolean; + images: ImageResourceInfoWithReferences[]; +} + +@Component({ + selector: 'tb-images-in-use-dialog', + templateUrl: './images-in-use-dialog.component.html', + styleUrls: ['./images-in-use-dialog.component.scss'] +}) +export class ImagesInUseDialogComponent extends + DialogComponent implements OnInit { + + title: string; + message: string; + + references: ImageReferences; + + dataSource: ImagesDatasource; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ImagesInUseDialogData, + public dialogRef: MatDialogRef, + public translate: TranslateService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private popoverService: TbPopoverService) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + if (this.data.multiple) { + this.title = this.translate.instant('image.images-are-in-use'); + this.message = this.translate.instant('image.images-are-in-use-text'); + this.dataSource = new ImagesDatasource(null, this.data.images, entity => true); + } else { + this.title = this.translate.instant('image.image-is-in-use'); + this.message = this.translate.instant('image.image-is-in-use-text', {title: this.data.images[0].title}); + this.references = this.data.images[0].references; + } + } + + cancel() { + this.dialogRef.close(null); + } + + delete() { + if (this.data.multiple) { + this.dialogRef.close(this.dataSource.selection.selected); + } else { + this.dialogRef.close(this.data.images); + } + } + + toggleShowReferences($event: Event, image: ImageResourceInfoWithReferences, referencesButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = referencesButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const referencesPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ImageReferencesComponent, 'top', true, null, + { + references: image.references + }, {}, {}, {}, + false, + visible => { + const addClasses = + visible ? 'mdc-button--unelevated mat-mdc-unelevated-button' : 'mdc-button--outlined mat-mdc-outlined-button'; + const removeClasses = + visible ? 'mdc-button--outlined mat-mdc-outlined-button' : 'mdc-button--unelevated mat-mdc-unelevated-button'; + addClasses.split(' ').forEach(clazz => { + this.renderer.addClass(trigger, clazz); + }); + removeClasses.split(' ').forEach(clazz => { + this.renderer.removeClass(trigger, clazz); + }); + }); + referencesPopover.tbComponentRef.instance.popoverComponent = referencesPopover; + } + } +} diff --git a/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.html b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.html new file mode 100644 index 0000000000..4a60b29ba9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.html @@ -0,0 +1,111 @@ + +
+ +
+
+
+ {{ 'image-input.images' | translate }} [{{ $index }}] +
+
+ drag_indicator +
+
+ +
+
+ +
+
+
+
{{ 'image-input.no-images' | translate }}
+
+
+
+ +
+
+
+ + +
+
+
+ + +
+
+
+ + +
{{ 'image.no-image-selected' | translate }}
+
diff --git a/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.scss b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.scss new file mode 100644 index 0000000000..f6e05ca2d9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.scss @@ -0,0 +1,225 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../scss/constants"; + +$imagesContainerHeight: 106px !default; +$selectContainerHeight: 96px !default; +$previewSize: 64px !default; + +.image-card { + margin-bottom: 8px; + &.image-dnd-placeholder { + height: 82px; + width: 146px; + border: 2px dashed rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } + &.image-dragging { + display: none !important; + } + .image-title { + font-size: 11px; + font-weight: 400; + line-height: 14px; + color: rgba(0, 0, 0, 0.6); + padding-bottom: 4px; + } + + .image-content-container { + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 4px; + height: $previewSize; + } + + .tb-image-preview { + width: auto; + max-width: $previewSize - 2px; + height: auto; + max-height: $previewSize - 2px; + } + + .tb-image-preview-container { + position: relative; + width: $previewSize; + height: $previewSize; + margin-top: -1px; + margin-bottom: -1px; + border: 1px solid rgba(0, 0, 0, 0.54); + + .tb-image-preview { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + .tb-image-action-container { + position: relative; + height: $previewSize - 2px; + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; + } +} + +:host { + .tb-container { + margin-top: 0; + padding: 0 0 16px; + display: flex; + flex-direction: column; + gap: 8px; + label.tb-title { + display: block; + padding-bottom: 0; + } + } + + .images-container { + padding: 12px 12px 4px; + background: rgba(0, 0, 0, 0.03); + border-radius: 4px; + flex-wrap: wrap; + margin-bottom: 8px; + &.no-images { + height: $imagesContainerHeight; + padding-bottom: 12px; + align-items: center; + justify-content: center; + } + } + + .no-images-prompt { + font-size: 18px; + color: rgba(0, 0, 0, 0.54); + } + + .tb-image-select-container { + width: 100%; + height: $selectContainerHeight; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + + .tb-image-container { + width: $selectContainerHeight - 1px; + height: $selectContainerHeight - 2px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + border-right: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; + overflow: hidden; + + .tb-image-preview { + width: auto; + max-width: $selectContainerHeight - 2px; + height: auto; + max-height: $selectContainerHeight - 2px; + } + + .tb-no-image { + text-align: center; + color: rgba(0, 0, 0, 0.38); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + } + } + + .tb-image-info-container { + display: flex; + flex: 1; + align-self: stretch; + padding: 0 8px; + justify-content: flex-end; + align-items: center; + gap: 4px; + + .tb-external-image-container { + display: flex; + flex: 1; + align-self: stretch; + padding: 16px 8px 0 16px; + flex-direction: column; + align-items: flex-start; + gap: 4px; + .tb-external-link-label { + color: rgba(0, 0, 0, 0.54); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.4px; + } + .tb-external-link-input-container { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 4px; + align-self: stretch; + .tb-inline-field { + width: 100%; + } + .tb-image-decline-btn { + color: rgba(0,0,0,0.38); + } + } + } + + } + + .tb-image-select-buttons-container { + display: flex; + flex: 1; + align-self: stretch; + padding: 8px; + gap: 8px; + justify-content: center; + align-items: flex-start; + .tb-image-select-button { + width: 100%; + height: 100%; + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + padding: 8px; + line-height: normal; + font-size: 12px; + @media #{$mat-gt-xs} { + padding: 16px; + } + .mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + margin: 0; + } + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.ts b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.ts new file mode 100644 index 0000000000..260a7667f8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.ts @@ -0,0 +1,181 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, forwardRef, Input, OnDestroy, Renderer2, ViewContainerRef } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { moveItemInArray } from '@angular/cdk/drag-drop'; +import { DndDropEvent } from 'ngx-drag-drop'; +import { isUndefined } from '@core/utils'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { ImageLinkType } from '@shared/components/image/gallery-image-input.component'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MatButton } from '@angular/material/button'; +import { ImageGalleryComponent } from '@shared/components/image/image-gallery.component'; +import { prependTbImagePrefixToUrls, removeTbImagePrefixFromUrls } from '@shared/models/resource.models'; + +@Component({ + selector: 'tb-multiple-gallery-image-input', + templateUrl: './multiple-gallery-image-input.component.html', + styleUrls: ['./multiple-gallery-image-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MultipleGalleryImageInputComponent), + multi: true + } + ] +}) +export class MultipleGalleryImageInputComponent extends PageComponent implements OnDestroy, ControlValueAccessor { + + @Input() + label: string; + + @Input() + @coerceBoolean() + required = false; + + @Input() + disabled: boolean; + + imageUrls: string[]; + + ImageLinkType = ImageLinkType; + + linkType: ImageLinkType = ImageLinkType.none; + + externalLinkControl = new FormControl(null); + + dragIndex: number; + + private propagateChange = null; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private popoverService: TbPopoverService) { + super(store); + } + + ngOnDestroy() { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: string[]): void { + this.reset(); + this.imageUrls = removeTbImagePrefixFromUrls(value); + } + + private updateModel() { + this.cd.markForCheck(); + this.propagateChange(prependTbImagePrefixToUrls(this.imageUrls)); + } + + private reset() { + this.linkType = ImageLinkType.none; + this.externalLinkControl.setValue(null, {emitEvent: false}); + } + + clearImage(index: number) { + this.imageUrls.splice(index, 1); + this.updateModel(); + } + + setLink($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.linkType = ImageLinkType.external; + } + + declineLink($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.reset(); + } + + applyLink($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.imageUrls.push(this.externalLinkControl.value); + this.reset(); + this.updateModel(); + } + + toggleGallery($event: Event, browseGalleryButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = browseGalleryButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + pageMode: false, + popoverMode: true, + mode: 'grid', + selectionMode: true + }; + const imageGalleryPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ImageGalleryComponent, 'top', true, null, + ctx, + {}, + {}, {}, true); + imageGalleryPopover.tbComponentRef.instance.imageSelected.subscribe((image) => { + imageGalleryPopover.hide(); + this.imageUrls.push(image.link); + this.updateModel(); + }); + } + } + + imageDragStart(index: number) { + setTimeout(() => { + this.dragIndex = index; + this.cd.markForCheck(); + }); + } + + imageDragEnd() { + this.dragIndex = -1; + this.cd.markForCheck(); + } + + imageDrop(event: DndDropEvent) { + let index = event.index; + if (isUndefined(index)) { + index = this.imageUrls.length; + } + moveItemInArray(this.imageUrls, this.dragIndex, index); + this.dragIndex = -1; + this.updateModel(); + } +} diff --git a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html new file mode 100644 index 0000000000..5255234ba8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html @@ -0,0 +1,65 @@ + +
+ +

{{ ( uploadImage ? 'image.upload-image' : 'image.update-image' ) | translate }}

+ + +
+ + +
+
+
+ + + + image.name + + + {{ 'image.name-required' | translate }} + + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.ts b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.ts new file mode 100644 index 0000000000..ae022615a1 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.ts @@ -0,0 +1,115 @@ +/// +/// 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, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + FormGroupDirective, + NgForm, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { ImageService } from '@core/http/image.service'; +import { ImageResourceInfo, imageResourceType } from '@shared/models/resource.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; + +export interface UploadImageDialogData { + image?: ImageResourceInfo; +} + +@Component({ + selector: 'tb-upload-image-dialog', + templateUrl: './upload-image-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: UploadImageDialogComponent}], + styleUrls: [] +}) +export class UploadImageDialogComponent extends + DialogComponent implements OnInit, ErrorStateMatcher { + + uploadImageFormGroup: UntypedFormGroup; + + uploadImage = true; + + submitted = false; + + maxResourceSize = getCurrentAuthState(this.store).maxResourceSize; + + constructor(protected store: Store, + protected router: Router, + private imageService: ImageService, + @Inject(MAT_DIALOG_DATA) public data: UploadImageDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + this.uploadImage = !this.data?.image; + this.uploadImageFormGroup = this.fb.group({ + file: [this.data?.image?.link, [Validators.required]] + }); + if (this.uploadImage) { + this.uploadImageFormGroup.addControl('title', this.fb.control(null, [Validators.required])); + } + } + + imageFileNameChanged(fileName: string) { + if (this.uploadImage) { + const titleControl = this.uploadImageFormGroup.get('title'); + if (!titleControl.value || !titleControl.touched) { + titleControl.setValue(fileName); + } + } + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + upload(): void { + this.submitted = true; + const file: File = this.uploadImageFormGroup.get('file').value; + if (this.uploadImage) { + const title: string = this.uploadImageFormGroup.get('title').value; + this.imageService.uploadImage(file, title).subscribe( + (res) => { + this.dialogRef.close(res); + } + ); + } else { + const image = this.data.image; + this.imageService.updateImage(imageResourceType(image), image.resourceKey, file).subscribe( + (res) => { + this.dialogRef.close(res); + } + ); + } + } +} diff --git a/ui-ngx/src/app/shared/components/js-func.component.html b/ui-ngx/src/app/shared/components/js-func.component.html index 41e834668e..e072759213 100644 --- a/ui-ngx/src/app/shared/components/js-func.component.html +++ b/ui-ngx/src/app/shared/components/js-func.component.html @@ -1,6 +1,6 @@ + + {{label}} + + + + warning + + + {{errorText}} + + + + + + + diff --git a/ui-ngx/src/app/shared/components/string-autocomplete.component.scss b/ui-ngx/src/app/shared/components/string-autocomplete.component.scss new file mode 100644 index 0000000000..39a4de620f --- /dev/null +++ b/ui-ngx/src/app/shared/components/string-autocomplete.component.scss @@ -0,0 +1,43 @@ +/** + * 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. + */ +:host { + mat-form-field { + display: flex; + flex: 1; + max-width: 100%; + box-sizing: border-box; + + .tb-autocomplete.tb-option-input-autocomplete { + .mat-mdc-option { + border-bottom: none; + + .mdc-list-item__primary-text { + flex: 1; + display: flex; + flex-direction: row; + gap: 8px; + + .tb-option { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/string-autocomplete.component.ts b/ui-ngx/src/app/shared/components/string-autocomplete.component.ts new file mode 100644 index 0000000000..a392450237 --- /dev/null +++ b/ui-ngx/src/app/shared/components/string-autocomplete.component.ts @@ -0,0 +1,180 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + Input, + forwardRef, + OnInit, + ViewChild, + ElementRef +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + FormControl, + Validators, + FormBuilder +} from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { tap, map, switchMap, take } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; + +@Component({ + selector: 'tb-string-autocomplete', + templateUrl: './string-autocomplete.component.html', + styleUrls: ['./string-autocomplete.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StringAutocompleteComponent), + multi: true + } + ] +}) +export class StringAutocompleteComponent implements ControlValueAccessor, OnInit { + + @ViewChild('nameInput', {static: true}) nameInput: ElementRef; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + required = false; + + @Input() + fetchOptionsFn: (searchText?: string) => Observable>; + + @Input() + placeholderText: string = this.translate.instant('widget-config.set'); + + @Input() + subscriptSizing: SubscriptSizing = 'dynamic'; + + @Input() + additionalClass: string | string[] | Record = 'tb-inline-field tb-suffix-show-on-hover'; + + @Input() + appearance: MatFormFieldAppearance = 'outline'; + + @Input() + label: string; + + @Input() + tooltipClass = 'tb-error-tooltip'; + + @Input() + errorText: string; + + @Input() + @coerceBoolean() + showInlineError = false; + + selectionFormControl: FormControl; + + modelValue: string | null; + + filteredOptions$: Observable>; + + searchText = ''; + + private dirty = false; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: FormBuilder, + private translate: TranslateService) { + } + + ngOnInit() { + this.selectionFormControl = this.fb.control('', this.required ? [Validators.required] : []); + this.filteredOptions$ = this.selectionFormControl.valueChanges + .pipe( + tap(value => this.updateView(value)), + map(value => value ? value : ''), + switchMap(value => this.fetchOptionsFn ? this.fetchOptionsFn(value) : of([])) + ); + } + + writeValue(option?: string): void { + this.searchText = ''; + this.modelValue = option ? option : null; + + if (this.fetchOptionsFn) { + this.fetchOptionsFn(option) + .pipe( + map(options => { + if (options) { + const foundOption = options.find(opt => opt === option); + return foundOption ? foundOption : option; + } + + return option; + }), + take(1) + ) + .subscribe(result => { + this.selectionFormControl.patchValue(result, { emitEvent: false }); + this.dirty = true; + }); + } else { + this.selectionFormControl.patchValue(null, { emitEvent: false }); + this.dirty = true; + } + } + + onFocus() { + if (this.dirty) { + this.selectionFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + updateView(value: string) { + this.searchText = value ? value : ''; + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectionFormControl.disable({emitEvent: false}); + } else { + this.selectionFormControl.enable({emitEvent: false}); + } + } + + clear() { + this.selectionFormControl.patchValue(null, {emitEvent: true}); + setTimeout(() => { + this.nameInput.nativeElement.blur(); + this.nameInput.nativeElement.focus(); + }, 0); + } +} diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.html b/ui-ngx/src/app/shared/components/string-items-list.component.html index 41723ce3d7..1b06cacf63 100644 --- a/ui-ngx/src/app/shared/components/string-items-list.component.html +++ b/ui-ngx/src/app/shared/components/string-items-list.component.html @@ -1,6 +1,6 @@
+ (selectedTabChange)="onTimewindowTypeChange()" [(selectedIndex)]="timewindow.selectedTab">
@@ -215,6 +216,7 @@ [(hideFlag)]="timewindow.hideAggInterval" (hideFlagChange)="onHideAggIntervalChanged()" [min]="minHistoryAggInterval()" [max]="maxHistoryAggInterval()" + useCalendarIntervals predefinedName="aggregation.group-interval"> diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss index 70f43f410c..25d11f510f 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2023 The Thingsboard Authors + * 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. diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts index f4a2e8b916..0c0dd4294c 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2023 The Thingsboard Authors +/// 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. @@ -20,6 +20,7 @@ import { AggregationType, DAY, HistoryWindowType, + QuickTimeInterval, quickTimeIntervalPeriod, RealtimeWindowType, Timewindow, @@ -186,6 +187,38 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { this.timewindowForm.get('aggregation.limit').updateValueAndValidity({emitEvent: false}); } + onTimewindowTypeChange() { + this.timewindowForm.markAsDirty(); + const timewindowFormValue = this.timewindowForm.getRawValue(); + if (this.timewindow.selectedTab === TimewindowType.REALTIME) { + if (timewindowFormValue.history.historyType !== HistoryWindowType.FIXED) { + this.timewindowForm.get('realtime').patchValue({ + realtimeType: Object.keys(RealtimeWindowType).includes(HistoryWindowType[timewindowFormValue.history.historyType]) ? + RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]] : + timewindowFormValue.realtime.realtimeType, + timewindowMs: timewindowFormValue.history.timewindowMs, + quickInterval: timewindowFormValue.history.quickInterval.startsWith('CURRENT') ? + timewindowFormValue.history.quickInterval : timewindowFormValue.realtime.quickInterval + }); + setTimeout(() => this.timewindowForm.get('realtime.interval').patchValue(timewindowFormValue.history.interval)); + } + } else { + this.timewindowForm.get('history').patchValue({ + historyType: HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]], + timewindowMs: timewindowFormValue.realtime.timewindowMs, + quickInterval: timewindowFormValue.realtime.quickInterval + }); + setTimeout(() => this.timewindowForm.get('history.interval').patchValue(timewindowFormValue.realtime.interval)); + } + this.timewindowForm.patchValue({ + aggregation: { + type: timewindowFormValue.aggregation.type, + limit: timewindowFormValue.aggregation.limit + }, + timezone: timewindowFormValue.timezone + }); + } + update() { const timewindowFormValue = this.timewindowForm.getRawValue(); this.timewindow.realtime = { diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.html b/ui-ngx/src/app/shared/components/time/timewindow.component.html index a53ea656fc..622f5654d9 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.html @@ -1,6 +1,6 @@