diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index bffb88ebb7..6d8889f98d 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -1,9 +1,9 @@ --- name: "\U0001F41E Bug report" about: Create a report to help us improve -title: "[Bug] " -labels: bug -assignees: ashvayka, vvlladd28 +title: "Your title here" +labels: ['bug', 'unconfirmed'] +assignees: AndriichnekoDm --- @@ -12,11 +12,13 @@ A clear and concise description of what the bug is. **Your Server Environment** -* demo.thingsboard.io -* cloud.thingsboard.io +* [https://demo.thingsboard.io](demo.thingsboard.io) +* [https://thingsboard.cloud](thingsboard.cloud) * own setup - * cloud or local infrastructure or docker deployment + * Deployment: monolith or microservices + * Deployment type: deb, rpm, exe, docker-compose, k8s, ami * ThingsBoard Version + * Community or Professional Edition * OS Name and Version **Your Client Environment** @@ -54,7 +56,7 @@ Steps to reproduce the behavior: A clear and concise description of what you expected to happen. **Screenshots** -If applicable, add screenshots to help explain your problem. +If applicable, please add screenshots to help explain your problem. **Additional context** -Add any other context about the problem here. +Please feel free to add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 8730336bba..c73f810cd8 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,9 +1,9 @@ --- name: Feature request about: Suggest an idea for this project -title: "[Feature Request]" -labels: feature -assignees: ikulikov +title: "Your title here" +labels: ['feature'] +assignees: 'AndriichnekoDm' --- diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 99d82ca698..2f15690f68 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -1,9 +1,9 @@ --- name: Question -about: Describe your questions in details -title: "[Question] your title here" -labels: question -assignees: ashvayka +about: Describe your questions in detail +title: "Your title here" +labels: ['question'] +assignees: 'AndriichnekoDm' --- @@ -16,7 +16,7 @@ assignees: ashvayka * Generic **Description** -A clear and concise details. +Clear and concise details. **Environment** 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/.github/workflows/license-header-format.yml b/.github/workflows/license-header-format.yml new file mode 100644 index 0000000000..20b5e7f063 --- /dev/null +++ b/.github/workflows/license-header-format.yml @@ -0,0 +1,54 @@ +# +# 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. +# + +name: License header format + +on: + push: + branches: + - 'master' + - 'develop/3*' + - 'hotfix/3*' + +jobs: + license-format: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'corretto' # https://github.com/actions/setup-java?tab=readme-ov-file#supported-distributions + java-version: '21' + cache: 'maven' # https://github.com/actions/setup-java?tab=readme-ov-file#caching-sbt-dependencies + + - name: License header format + run: mvn -T 1C license:format + + - name: License header format (msa/black-box-tests/) + run: mvn -T 1C license:format -f msa/black-box-tests/ + + - name: Set Git user information + run: | + git config user.name "ThingsBoard Bot" + git config user.email "noreply@thingsboard.io" + + - name: Check and push changes + run: | + git diff --exit-code || git commit -am "License header format" && git push diff --git a/application/pom.xml b/application/pom.xml index ee01bfbaf3..7fa427cd89 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -1,6 +1,6 @@ - - diff --git a/build_proto.sh b/build_proto.sh index 55fdea0880..b27dca325b 100755 --- a/build_proto.sh +++ b/build_proto.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# 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/actor/pom.xml b/common/actor/pom.xml index 7fa770c811..c0e13c2cbe 100644 --- a/common/actor/pom.xml +++ b/common/actor/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/application/src/main/java/org/thingsboard/server/service/queue/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java similarity index 93% rename from application/src/main/java/org/thingsboard/server/service/queue/ProtoUtils.java rename to common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 76a8e3e89a..9daa9de9da 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.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.service.queue; +package org.thingsboard.server.common.util; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.DeviceId; @@ -46,6 +46,7 @@ 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; @@ -152,22 +153,29 @@ public class ProtoUtils { } private static TransportProtos.DeviceEdgeUpdateMsgProto toProto(DeviceEdgeUpdateMsg msg) { - return TransportProtos.DeviceEdgeUpdateMsgProto.newBuilder() + 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()) - .setEdgeIdMSB(msg.getEdgeId().getId().getMostSignificantBits()) - .setEdgeIdLSB(msg.getEdgeId().getId().getLeastSignificantBits()) - .build(); + .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())), - new EdgeId(new UUID(proto.getEdgeIdMSB(), proto.getEdgeIdLSB())) - ); + edgeId); } private static TransportProtos.DeviceNameOrTypeUpdateMsgProto toProto(DeviceNameOrTypeUpdateMsg msg) { @@ -377,6 +385,21 @@ public class ProtoUtils { ); } + 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; @@ -406,6 +429,10 @@ public class ProtoUtils { 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; } @@ -425,6 +452,8 @@ public class ProtoUtils { return fromProto(proto.getFromDeviceRpcResponseMsg()); } else if (proto.hasRemoveRpcActorMsg()) { return fromProto(proto.getRemoveRpcActorMsg()); + } else if (proto.hasDeviceDeleteMsg()) { + return fromProto(proto.getDeviceDeleteMsg()); } return null; } 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 95% rename from common/cluster-api/src/main/proto/queue.proto rename to common/proto/src/main/proto/queue.proto index 6116411b2e..6e33faaf69 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. @@ -255,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 { @@ -300,6 +306,17 @@ message CoreStartupMsg { 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; @@ -534,6 +551,14 @@ message GetOtaPackageResponseMsg { string fileName = 8; } +message DeviceConnectProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + int64 lastConnectTime = 5; +} + message DeviceActivityProto { int64 tenantIdMSB = 1; int64 tenantIdLSB = 2; @@ -542,6 +567,22 @@ message DeviceActivityProto { int64 lastActivityTime = 5; } +message DeviceDisconnectProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + int64 lastDisconnectTime = 5; +} + +message DeviceInactivityProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + int64 lastInactivityTime = 5; +} + //Used to report session state to tb-Service and persist this state in the cache on the tb-Service level. message SubscriptionInfoProto { int64 lastActivityTime = 1; @@ -910,8 +951,8 @@ message EdgeNotificationMsgProto { string body = 10; PostTelemetryMsg postTelemetryMsg = 11; PostAttributeMsg postAttributesMsg = 12; - int64 sourceEdgeIdMSB = 13; - int64 sourceEdgeIdLSB = 14; + int64 originatorEdgeIdMSB = 13; + int64 originatorEdgeIdLSB = 14; } message EdgeEventUpdateMsgProto { @@ -945,8 +986,8 @@ message DeviceEdgeUpdateMsgProto { int64 tenantIdLSB = 2; int64 deviceIdMSB = 3; int64 deviceIdLSB = 4; - int64 edgeIdMSB = 5; - int64 edgeIdLSB = 6; + optional int64 edgeIdMSB = 5; + optional int64 edgeIdLSB = 6; } message DeviceNameOrTypeUpdateMsgProto { @@ -1004,6 +1045,13 @@ message RemoveRpcActorMsgProto { 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; @@ -1012,6 +1060,7 @@ message ToDeviceActorNotificationMsgProto { ToDeviceRpcRequestActorMsgProto toDeviceRpcRequestMsg = 5; FromDeviceRpcResponseActorMsgProto fromDeviceRpcResponseMsg = 6; RemoveRpcActorMsgProto removeRpcActorMsg = 7; + DeviceDeleteMsgProto deviceDeleteMsg = 8; } /** @@ -1246,6 +1295,9 @@ message ToCoreMsg { LifecycleEventProto lifecycleEventMsg = 8; ErrorEventProto errorEventMsg = 9; ToDeviceActorNotificationMsgProto toDeviceActorNotification = 10; + DeviceConnectProto deviceConnectMsg = 50; + DeviceDisconnectProto deviceDisconnectMsg = 51; + DeviceInactivityProto deviceInactivityMsg = 52; } /* High priority messages with low latency are handled by ThingsBoard Core Service separately */ @@ -1255,8 +1307,8 @@ message ToCoreNotificationMsg { FromDeviceRPCResponseProto fromDeviceRpcResponse = 2; bytes componentLifecycleMsg = 3 [deprecated = true]; bytes edgeEventUpdateMsg = 4 [deprecated = true]; - QueueUpdateMsg queueUpdateMsg = 5; - QueueDeleteMsg queueDeleteMsg = 6; + repeated QueueUpdateMsg queueUpdateMsgs = 5; + repeated QueueDeleteMsg queueDeleteMsgs = 6; VersionControlResponseMsg vcResponseMsg = 7; bytes toEdgeSyncRequestMsg = 8 [deprecated = true]; bytes fromEdgeSyncResponseMsg = 9 [deprecated = true]; @@ -1267,6 +1319,7 @@ message ToCoreNotificationMsg { EdgeEventUpdateMsgProto edgeEventUpdate = 14; ToEdgeSyncRequestMsgProto toEdgeSyncRequest = 15; FromEdgeSyncResponseMsgProto fromEdgeSyncResponse = 16; + ResourceCacheInvalidateMsg resourceCacheInvalidateMsg = 17; } /* Messages that are handled by ThingsBoard RuleEngine Service */ @@ -1281,8 +1334,8 @@ message ToRuleEngineMsg { message ToRuleEngineNotificationMsg { bytes componentLifecycleMsg = 1 [deprecated = true]; FromDeviceRPCResponseProto fromDeviceRpcResponse = 2; - QueueUpdateMsg queueUpdateMsg = 3; - QueueDeleteMsg queueDeleteMsg = 4; + repeated QueueUpdateMsg queueUpdateMsgs = 3; + repeated QueueDeleteMsg queueDeleteMsgs = 4; ComponentLifecycleMsgProto componentLifecycle = 5; } @@ -1302,8 +1355,8 @@ message ToTransportMsg { ResourceUpdateMsg resourceUpdateMsg = 12; ResourceDeleteMsg resourceDeleteMsg = 13; UplinkNotificationMsg uplinkNotificationMsg = 14; - QueueUpdateMsg queueUpdateMsg = 15; - QueueDeleteMsg queueDeleteMsg = 16; + repeated QueueUpdateMsg queueUpdateMsgs = 15; + repeated QueueDeleteMsg queueDeleteMsgs = 16; } message UsageStatsKVProto{ 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/application/src/test/java/org/thingsboard/server/service/queue/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java similarity index 99% rename from application/src/test/java/org/thingsboard/server/service/queue/ProtoUtilsTest.java rename to common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java index 1a696b730d..4bb98f6cd2 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ProtoUtilsTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.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.service.queue; +package org.thingsboard.server.common.util; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; 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 @@ + 4.5.1 2.6.0 5.13.1 1.3.0 @@ -153,6 +154,9 @@ 1.12.1 6.4.2 1.34.1 + 1.17 + 2.19.0 + 8.0.1 @@ -893,6 +897,11 @@ version-control ${project.version} + + org.thingsboard.common + proto + ${project.version} + org.thingsboard.common cache @@ -1642,6 +1651,12 @@ + + org.mockito + mockito-inline + ${mockito.version} + test + org.testng testng @@ -2004,6 +2019,11 @@ slack-api-client ${slack-api.version} + + com.google.firebase + firebase-admin + ${firebase-admin.version} + org.eclipse.jgit org.eclipse.jgit @@ -2029,6 +2049,21 @@ google-oauth-client ${google-oauth-client.version} + + org.apache.xmlgraphics + batik-transcoder + ${apache-xmlgraphics.version} + + + org.apache.xmlgraphics + batik-codec + ${apache-xmlgraphics.version} + + + com.drewnoakes + metadata-extractor + ${drewnoakes-metadata-extractor.version} + diff --git a/rest-client/pom.xml b/rest-client/pom.xml index 8651656744..1bdd531fe2 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -1,6 +1,6 @@ - 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() }}
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.scss index 6f7eb4bd3b..5c576ecf2f 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-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/modules/home/components/dashboard-page/add-widget-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts index ffc453c42a..2089c03d5d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts @@ -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,6 +29,7 @@ import { WidgetConfigComponentData, WidgetInfo } from '@home/models/widget-compo import { isDefined, isDefinedAndNotNull, isString } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; export interface AddWidgetDialogData { dashboard: Dashboard; @@ -97,6 +98,7 @@ export class AddWidgetDialogComponent extends DialogComponent
-
{{ 'dashboard.no-image' | translate }}
- +
{{ 'dashboard.no-image' | translate }}
+
Right % @@ -62,9 +62,9 @@
- - +
-
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss index 09ba2d7f95..e62d598613 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.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/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 6f6e39c52c..f3d0e2ee15 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.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. @@ -55,7 +55,7 @@ import { } from '@app/shared/models/dashboard.models'; import { WINDOW } from '@core/services/window.service'; import { WindowMessage } from '@shared/models/window-message.model'; -import { deepClone, guid, isDefined, isDefinedAndNotNull, isNotEmptyStr } from '@app/core/utils'; +import { deepClone, guid, isDefined, isDefinedAndNotNull, isEqual, isNotEmptyStr } from '@app/core/utils'; import { DashboardContext, DashboardPageInitData, @@ -119,7 +119,7 @@ import { ManageDashboardStatesDialogComponent, ManageDashboardStatesDialogData } from '@home/components/dashboard-page/states/manage-dashboard-states-dialog.component'; -import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { ImportExportService } from '@shared/import-export/import-export.service'; import { AuthState } from '@app/core/auth/auth.models'; import { FiltersDialogComponent, FiltersDialogData } from '@home/components/filter/filters-dialog.component'; import { Filters } from '@shared/models/query/query.models'; @@ -145,7 +145,7 @@ import { IAliasController } from '@core/api/widget-api.models'; import { MatButton } from '@angular/material/button'; import { VersionControlComponent } from '@home/components/vc/version-control.component'; import { TbPopoverService } from '@shared/components/popover.service'; -import { tap } from 'rxjs/operators'; +import { map, tap } from 'rxjs/operators'; import { LayoutFixedSize, LayoutWidthType } from '@home/components/dashboard-page/layout/layout.models'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { ResizeObserver } from '@juggle/resize-observer'; @@ -270,6 +270,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC state: null, stateController: null, stateChanged: null, + stateId: null, aliasController: null, runChangeDetection: this.runChangeDetection.bind(this) }; @@ -668,12 +669,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC } } - public get dashboardLogo(): SafeUrl { - if (!this.dashboardLogoCache) { - const logo = this.dashboard.configuration.settings.dashboardLogoUrl || this.defaultDashboardLogo; - this.dashboardLogoCache = this.sanitizer.bypassSecurityTrustUrl(logo); - } - return this.dashboardLogoCache; + public get dashboardLogo(): string { + return this.dashboard.configuration.settings.dashboardLogoUrl || this.defaultDashboardLogo; } public showRightLayoutSwitch(): boolean { @@ -872,15 +869,21 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC $event.stopPropagation(); } this.dialog.open(ManageDashboardStatesDialogComponent, { + {states: {[id: string]: DashboardState}; widgets: {[id: string]: Widget}}>(ManageDashboardStatesDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { - states: deepClone(this.dashboard.configuration.states) + states: deepClone(this.dashboard.configuration.states), + widgets: deepClone(this.dashboard.configuration.widgets) as {[id: string]: Widget} } - }).afterClosed().subscribe((states) => { - if (states) { - this.updateStates(states); + }).afterClosed().subscribe((result) => { + if (result) { + if (!isEqual(result.widgets, this.dashboard.configuration.widgets)) { + this.dashboard.configuration.widgets = result.widgets; + } + if (result.states) { + this.updateStates(result.states); + } } }); } @@ -923,6 +926,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC $event.stopPropagation(); } this.importExport.importWidget(this.dashboard, this.dashboardCtx.state, + this.editMissingAliases.bind(this), this.selectTargetLayout.bind(this), this.entityAliasesUpdated.bind(this), this.filtersUpdated.bind(this)).subscribe( (importData) => { if (importData) { @@ -939,6 +943,30 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC ); } + private editMissingAliases(widgets: Array, isSingleWidget: boolean, + customTitle: string, missingEntityAliases: EntityAliases): Observable { + return this.dialog.open(EntityAliasesDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entityAliases: missingEntityAliases, + widgets, + customTitle, + isSingleWidget, + disableAdd: true + } + }).afterClosed().pipe( + map((updatedEntityAliases) => { + if (updatedEntityAliases) { + return updatedEntityAliases; + } else { + throw new Error('Unable to resolve missing entity aliases!'); + } + } + )); + } + public currentDashboardIdChanged(dashboardId: string) { if (!this.widgetEditMode) { this.dashboardCtx.stateController.cleanupPreservedStates(); @@ -1022,26 +1050,20 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC this.onAddWidgetClosed(); this.isAddingWidgetClosed = true; } - if (this.widgetEditMode) { - if (revert) { - this.dashboard = this.prevDashboard; - this.dashboardLogoCache = undefined; - this.dashboardConfiguration = this.dashboard.configuration; - } - } else { - this.resetHighlight(); - if (revert) { - this.dashboard = this.prevDashboard; - this.dashboardLogoCache = undefined; - this.dashboardConfiguration = this.dashboard.configuration; + this.resetHighlight(); + if (revert) { + this.dashboard = this.prevDashboard; + this.dashboardLogoCache = undefined; + this.dashboardConfiguration = this.dashboard.configuration; + if (!this.widgetEditMode) { this.dashboardCtx.dashboardTimewindow = this.dashboardConfiguration.timewindow; this.updateDashboardCss(); this.entityAliasesUpdated(); this.filtersUpdated(); this.updateLayouts(); - } else { - this.dashboard.configuration.timewindow = this.dashboardCtx.dashboardTimewindow; } + } else if (!this.widgetEditMode) { + this.dashboard.configuration.timewindow = this.dashboardCtx.dashboardTimewindow; } } } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts index 63c4a555ac..2f18de2e6e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.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. @@ -39,6 +39,7 @@ export interface DashboardContext { aliasController: IAliasController; stateController: IStateController; stateChanged: Observable; + stateId: Observable; runChangeDetection: () => void; } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index 2090e5f42c..03640012dd 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -1,6 +1,6 @@ - +

dashboard.manage-states

@@ -96,7 +96,7 @@ - +
@@ -107,6 +107,13 @@ (click)="editState($event, state)"> edit + +
- - - - - 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..3425065ca2 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,37 @@ * 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; + min-height: 42px; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + } } button.clipboard-btn { - right: 0; + right: -2px; p { color: #305680; } @@ -60,9 +69,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 31f98aff6c..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) { @@ -190,7 +197,7 @@ export class BatteryLevelWidgetComponent implements OnInit, OnDestroy, AfterView 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,9 +228,10 @@ 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.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 index 4c14686b9c..5c247ca794 100644 --- 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 @@ -1,6 +1,6 @@ -
+
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 index 046d1a7d3e..360a75f54c 100644 --- 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 @@ -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/liquid-level-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts index 9bb7053c3e..0933bfe537 100644 --- 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 @@ -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. @@ -58,6 +58,9 @@ 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', @@ -76,7 +79,7 @@ export class LiquidLevelWidgetComponent implements OnInit { @Input() widgetTitlePanel: TemplateRef; - backgroundStyle: ComponentStyle = {}; + backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; hasCardClickAction = false; @@ -103,10 +106,13 @@ export class LiquidLevelWidgetComponent implements OnInit { private volume: number; private tooltipContent: string; private widgetUnits: string; + private volumeUnits: string; private capacityUnits = Object.values(CapacityUnits); - constructor(private cd: ChangeDetectorRef, + constructor(private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, + private cd: ChangeDetectorRef, private resourcesService: ResourcesService, private translate: TranslateService) { } @@ -116,14 +122,14 @@ export class LiquidLevelWidgetComponent implements OnInit { this.settings = {...levelCardDefaultSettings, ...this.ctx.settings}; this.declareStyles(); - 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; this.getData().subscribe(data => { if (data) { - const { svg, volume, units } = data; + const { svg, volume, units, volumeUnits } = data; if (svg && isNotEmptyStr(svg) && this.liquidLevelContent.nativeElement) { const jQueryContainerElement = $(this.liquidLevelContent.nativeElement); jQueryContainerElement.html(svg); @@ -140,6 +146,10 @@ export class LiquidLevelWidgetComponent implements OnInit { this.volume = Number(volume); } + if (volumeUnits) { + this.volumeUnits = volumeUnits; + } + if (units) { this.widgetUnits = units; } @@ -159,27 +169,31 @@ export class LiquidLevelWidgetComponent implements OnInit { this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat); } - private getData(): Observable<{ svg: string; volume: number; units: string }> { - const entityId: EntityId = { - entityType: this.ctx.datasources[0].entityType, - id: this.ctx.datasources[0].entityId - }; + private getData(): Observable<{ svg: string; volume: number; units: string; volumeUnits: 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 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() { @@ -299,7 +313,7 @@ export class LiquidLevelWidgetComponent implements OnInit { .pipe(map(attributes => { const shape = extractValue(attributes, this.settings.shapeAttributeName); if (!shape || !svgMapping.has(shape)) { - this.createdErrorMgs(this.settings.shapeAttributeName, isUndefinedOrNull(shape) || isEmptyStr(shape)); + this.createdErrorMsg(this.settings.shapeAttributeName, isUndefinedOrNull(shape) || isEmptyStr(shape)); return this.settings.selectedShape; } return shape; @@ -309,12 +323,15 @@ export class LiquidLevelWidgetComponent implements OnInit { return of(this.settings.selectedShape); } - private getTankersParams(entityId: EntityId): Observable<{ volume: number; units: string }> { + private getTankersParams(entityId: EntityId): Observable<{ volume: number; units: string; volumeUnits: 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 isVolumeUnitStatic = this.settings.layout !== LevelCardLayout.absolute + && this.settings.datasourceUnits === CapacityUnits.percent + || this.settings.volumeUnitsSource === LiquidWidgetDataSourceType.static; const attributeKeys: string[] = []; @@ -326,20 +343,29 @@ export class LiquidLevelWidgetComponent implements OnInit { attributeKeys.push(this.settings.widgetUnitsAttributeName); } + if (!isVolumeUnitStatic) { + attributeKeys.push(this.settings.volumeUnitsAttributeName); + } + if (!attributeKeys.length || entityId.id === NULL_UUID) { return of({ volume: this.settings.volumeConstant, + volumeUnits: this.settings.volumeUnits, 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); + let volume = isVolumeStatic ? this.settings.volumeConstant : + extractValue(attributes, this.settings.volumeAttributeName); + let volumeUnits = isVolumeUnitStatic ? this.settings.volumeUnits : + extractValue(attributes, this.settings.volumeUnitsAttributeName); + 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)); + this.createdErrorMsg(this.settings.volumeAttributeName, isUndefinedOrNull(volume) || isEmptyStr(volume)); volume = this.settings.volumeConstant; } @@ -349,20 +375,33 @@ export class LiquidLevelWidgetComponent implements OnInit { units = this.capacityUnits.find(unit => unit.normalize() === normalizeUnits); } if (isUndefinedOrNull(units) || !isNotEmptyStr(units)) { - this.createdErrorMgs(this.settings.widgetUnitsAttributeName, isUndefinedOrNull(units) || isEmptyStr(units)); + this.createdErrorMsg(this.settings.widgetUnitsAttributeName, isUndefinedOrNull(units) || isEmptyStr(units)); units = this.settings.units; } } + if (!isVolumeUnitStatic) { + if (isNotEmptyStr(volumeUnits)) { + const normalizeUnits = volumeUnits.normalize().trim(); + volumeUnits = this.capacityUnits.find(unit => unit.normalize() === normalizeUnits); + } + if (isUndefinedOrNull(volumeUnits) || !isNotEmptyStr(volumeUnits)) { + this.createdErrorMsg(this.settings.widgetUnitsAttributeName, + isUndefinedOrNull(volumeUnits) || isEmptyStr(volumeUnits)); + volumeUnits = this.settings.volumeUnits; + } + } + return { volume, + volumeUnits, units }; }) ); } - private createdErrorMgs(attributeName: string, isEmpty = false) { + private createdErrorMsg(attributeName: string, isEmpty = false) { if (isEmpty) { this.errorsMsg.push(this.translate.instant('widgets.liquid-level-card.attribute-key-not-set', {attributeName})); } else { @@ -391,7 +430,7 @@ export class LiquidLevelWidgetComponent implements OnInit { return limits.min + (percentage / 100) * (limits.max - limits.min); } - private updateTooltip(value: [number, any]): void { + private updateTooltip(value: DataEntry): void { this.tooltipContent = this.getTooltipContent(value); if (this.tooltip) { @@ -465,9 +504,14 @@ export class LiquidLevelWidgetComponent implements OnInit { } 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); + let volume: number | string; + if (this.widgetUnits !== CapacityUnits.percent) { + const volumeInLiters: number = convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to); + volume = convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from) + .toFixed(this.settings.decimals || 0); + } else { + volume = this.volume.toFixed(this.settings.decimals || 0); + } const volumeTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.volumeFont), color: this.settings.volumeColor}); @@ -486,7 +530,7 @@ export class LiquidLevelWidgetComponent implements OnInit { } } - private getTooltipContent(value?: [number, any]): string { + private getTooltipContent(value?: DataEntry): string { const contentValue = value || [0, '']; let tooltipValue: string | number = 'N/A'; @@ -541,18 +585,18 @@ export class LiquidLevelWidgetComponent implements OnInit {
`; } - private convertInputData(value: number): number { + private convertInputData(value: any): number { if (this.settings.datasourceUnits !== CapacityUnits.percent) { - return (convertLiters(value, this.settings.datasourceUnits, ConversionType.to) / - convertLiters(this.volume, this.settings.volumeUnits, ConversionType.to)) * 100; + return (convertLiters(Number(value), this.settings.datasourceUnits, ConversionType.to) / + convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to)) * 100; } - return value; + 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 convertLiters(this.volume * (value / 100), this.volumeUnits as CapacityUnits, ConversionType.to); } return value; 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 index cf526d6c97..632cfe570d 100644 --- 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 @@ -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. @@ -56,6 +56,8 @@ export interface LevelCardWidgetSettings extends WidgetConfig { volumeSource: LiquidWidgetDataSourceType; volumeConstant: number; volumeAttributeName: string; + volumeUnitsSource: LiquidWidgetDataSourceType; + volumeUnitsAttributeName: string; volumeUnits: CapacityUnits; volumeFont: Font; volumeColor: string; @@ -257,8 +259,10 @@ export const levelCardDefaultSettings: LevelCardWidgetSettings = { iconColor: '#5469FF', volumeSource: LiquidWidgetDataSourceType.static, volumeConstant: 500, - volumeUnits: CapacityUnits.liters, volumeAttributeName: 'volume', + volumeUnitsSource: LiquidWidgetDataSourceType.static, + volumeUnitsAttributeName: 'volumeUnits', + volumeUnits: CapacityUnits.liters, volumeFont: { family: 'Roboto', size: 14, @@ -375,9 +379,7 @@ export const convertLiters = (value: number, units: CapacityUnits, conversionTyp 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 extractValue = (attributes: Array, attributeName: string): T | undefined => attributes.find(attr => attr.key === attributeName)?.value; export const valueContainerStyleDefaults = cssTextFromInlineStyle({ width: '100%', @@ -418,7 +420,7 @@ export const createAbsoluteLayout = (values?: {inputValue: number | string; volu export const createPercentLayout = (value: number | string = 50, valueTextStyle: string = valueTextStyleDefaults): string => `
- +
`; export const optionsFilter = (searchText: string): ((key: DataKey) => boolean) => @@ -494,6 +496,7 @@ 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 volumeUnitsSource: LiquidWidgetDataSourceType = formGroup.get('volumeUnitsSource').value; const widgetUnitsSource: LiquidWidgetDataSourceType = formGroup.get('widgetUnitsSource').value; const showTooltipLevel: boolean = formGroup.get('showTooltipLevel').value; const showTooltipDate: boolean = formGroup.get('showTooltipDate').value; @@ -517,7 +520,7 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => { if (datasourceUnits !== CapacityUnits.percent) { formGroup.get('volumeSource').enable({emitEvent: false}); - formGroup.get('volumeUnits').enable({emitEvent: false}); + formGroup.get('volumeUnitsSource').enable({emitEvent: false}); if (volumeSource === LiquidWidgetDataSourceType.static) { formGroup.get('volumeConstant').enable({emitEvent: false}); formGroup.get('volumeAttributeName').disable({emitEvent: false}); @@ -525,11 +528,20 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => { formGroup.get('volumeConstant').disable({emitEvent: false}); formGroup.get('volumeAttributeName').enable({emitEvent: false}); } + if (volumeUnitsSource === LiquidWidgetDataSourceType.static) { + formGroup.get('volumeUnits').enable({emitEvent: false}); + formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false}); + } else { + formGroup.get('volumeUnits').disable({emitEvent: false}); + formGroup.get('volumeUnitsAttributeName').enable({emitEvent: false}); + } } else { formGroup.get('volumeSource').disable({emitEvent: false}); formGroup.get('volumeConstant').disable({emitEvent: false}); formGroup.get('volumeAttributeName').disable({emitEvent: false}); + formGroup.get('volumeUnitsSource').disable({emitEvent: false}); formGroup.get('volumeUnits').disable({emitEvent: false}); + formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false}); } if (layout === LevelCardLayout.simple) { @@ -557,7 +569,7 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => { } formGroup.get('volumeSource').enable({emitEvent: false}); - formGroup.get('volumeUnits').enable({emitEvent: false}); + formGroup.get('volumeUnitsSource').enable({emitEvent: false}); if (volumeSource === LiquidWidgetDataSourceType.static) { formGroup.get('volumeConstant').enable({emitEvent: false}); formGroup.get('volumeAttributeName').disable({emitEvent: false}); @@ -565,6 +577,13 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => { formGroup.get('volumeConstant').disable({emitEvent: false}); formGroup.get('volumeAttributeName').enable({emitEvent: false}); } + if (volumeUnitsSource === LiquidWidgetDataSourceType.static) { + formGroup.get('volumeUnits').enable({emitEvent: false}); + formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false}); + } else { + formGroup.get('volumeUnits').disable({emitEvent: false}); + formGroup.get('volumeUnitsAttributeName').enable({emitEvent: false}); + } if (formGroup.get('decimals')) { formGroup.get('decimals').enable({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 8427cf362b..ef60683d70 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) { @@ -136,7 +157,6 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi ngOnInit(): void { this.ctx.$scope.signalStrengthWidget = this; this.settings = {...signalStrengthDefaultSettings, ...this.ctx.settings}; - this.layout = this.settings.layout; this.showDate = this.settings.showDate; @@ -186,7 +206,7 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi 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; @@ -241,7 +261,7 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi } } - this.noSignal = this.rssi <= -100; + this.noSignal = this.rssi <= this.settings.noSignalRssiValue; this.activeBarsColor.update(this.rssi); @@ -383,5 +403,4 @@ export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterVi this.renderer.setStyle(this.signalStrengthTooltip.nativeElement, 'transform', `scale(${scale})`); } } - } 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..6b426197d0 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. @@ -53,6 +53,7 @@ export interface SignalStrengthWidgetSettings { dateFont: Font; dateColor: string; activeBarsColor: ColorSettings; + noSignalRssiValue: number; inactiveBarsColor: string; showTooltip: boolean; showTooltipValue: boolean; @@ -91,6 +92,7 @@ export const signalStrengthDefaultSettings: SignalStrengthWidgetSettings = { ], colorFunction: defaultColorFunction }, + noSignalRssiValue: -100, inactiveBarsColor: 'rgba(224, 224, 224, 1)', showTooltip: true, showTooltipValue: true, 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 @@ +
+
+ +
+
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.scss new file mode 100644 index 0000000000..d65341a7ba --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.scss @@ -0,0 +1,64 @@ +/** + * 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-power-button-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 24px 24px 24px; + > div:not(.tb-power-button-overlay) { + z-index: 1; + } + .tb-power-button-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + div.tb-widget-title { + padding: 0; + } + .tb-power-button-content { + flex: 1; + min-width: 0; + min-height: 0; + .tb-power-button-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + svg { + .tb-small-shadow { + filter: drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.2)); + } + .tb-shadow { + filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.15)); + } + } + &.tb-power-button-pointer { + svg { + .tb-hover-circle { + cursor: pointer; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts new file mode 100644 index 0000000000..02c58b3e9a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts @@ -0,0 +1,203 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + OnInit, + Renderer2, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; +import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; +import { Observable } from 'rxjs'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { ValueType } from '@shared/models/constants'; +import { + powerButtonDefaultSettings, + PowerButtonShape, + powerButtonShapeSize, + PowerButtonWidgetSettings +} from '@home/components/widget/lib/rpc/power-button-widget.models'; +import { SVG, Svg } from '@svgdotjs/svg.js'; + +@Component({ + selector: 'tb-power-button-widget', + templateUrl: './power-button-widget.component.html', + styleUrls: ['../action/action-widget.scss', './power-button-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class PowerButtonWidgetComponent extends + BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { + + @ViewChild('powerButtonShape', {static: false}) + powerButtonShape: ElementRef; + + settings: PowerButtonWidgetSettings; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + + value = false; + disabled = false; + + private shapeResize$: ResizeObserver; + private drawSvgShapePending = false; + private svgShape: Svg; + private powerButtonSvgShape: PowerButtonShape; + private disabledState = false; + + private onValueSetter: ValueSetter; + private offValueSetter: ValueSetter; + + constructor(protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + private renderer: Renderer2, + protected cd: ChangeDetectorRef) { + super(cd); + } + + ngOnInit(): void { + super.ngOnInit(); + this.settings = {...powerButtonDefaultSettings, ...this.ctx.settings}; + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + + const getInitialStateSettings = + {...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.initial-state')}; + this.createValueGetter(getInitialStateSettings, ValueType.BOOLEAN, { + next: (value) => this.onValue(value) + }); + + const disabledStateSettings = + {...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.disabled-state')}; + this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, { + next: (value) => this.onDisabled(value) + }); + + const onUpdateStateSettings = {...this.settings.onUpdateState, + actionLabel: this.ctx.translate.instant('widgets.power-button.power-on')}; + this.onValueSetter = this.createValueSetter(onUpdateStateSettings); + + const offUpdateStateSettings = {...this.settings.offUpdateState, + actionLabel: this.ctx.translate.instant('widgets.power-button.power-off')}; + this.offValueSetter = this.createValueSetter(offUpdateStateSettings); + + this.loading$.subscribe((loading) => { + this.updateDisabledState(loading || this.disabled); + this.cd.markForCheck(); + }); + } + + ngAfterViewInit(): void { + if (this.drawSvgShapePending) { + this.drawSvg(); + } + super.ngAfterViewInit(); + } + + ngOnDestroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + super.ngOnDestroy(); + } + + public onInit() { + super.onInit(); + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + if (this.powerButtonShape) { + this.drawSvg(); + } else { + this.drawSvgShapePending = true; + } + this.cd.detectChanges(); + } + + private onValue(value: boolean): void { + const newValue = !!value; + if (this.value !== newValue) { + this.value = newValue; + this.powerButtonSvgShape?.setValue(this.value); + this.cd.markForCheck(); + } + } + + private onDisabled(value: boolean): void { + const newDisabled = !!value; + if (this.disabled !== newDisabled) { + this.disabled = newDisabled; + this.updateDisabledState(this.disabled); + this.cd.markForCheck(); + } + } + + private onClick() { + if (!this.ctx.isEdit && !this.ctx.isPreview && !this.disabledState) { + this.onValue(!this.value); + const targetValue = this.value; + const targetSetter = targetValue ? this.onValueSetter : this.offValueSetter; + this.powerButtonSvgShape?.setPressed(true); + this.updateValue(targetSetter, targetValue, { + next: () => { + this.powerButtonSvgShape?.setPressed(false); + this.onValue(targetValue); + }, + error: () => { + this.powerButtonSvgShape?.setPressed(false); + this.onValue(!targetValue); + } + }); + } + } + + private drawSvg() { + this.svgShape = SVG().addTo(this.powerButtonShape.nativeElement).size(powerButtonShapeSize, powerButtonShapeSize); + this.renderer.setStyle(this.svgShape.node, 'overflow', 'visible'); + this.renderer.setStyle(this.svgShape.node, 'user-select', 'none'); + + this.powerButtonSvgShape = PowerButtonShape.fromSettings(this.ctx, this.svgShape, + this.settings, this.value, this.disabledState, () => this.onClick()); + + this.shapeResize$ = new ResizeObserver(() => { + this.onResize(); + }); + this.shapeResize$.observe(this.powerButtonShape.nativeElement); + this.onResize(); + } + + private updateDisabledState(disabled: boolean) { + this.disabledState = disabled; + this.powerButtonSvgShape?.setDisabled(this.disabledState); + } + + private onResize() { + const shapeWidth = this.powerButtonShape.nativeElement.getBoundingClientRect().width; + const shapeHeight = this.powerButtonShape.nativeElement.getBoundingClientRect().height; + const size = Math.min(shapeWidth, shapeHeight); + const scale = size / powerButtonShapeSize; + this.renderer.setStyle(this.svgShape.node, 'transform', `scale(${scale})`); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts new file mode 100644 index 0000000000..51a427d23b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts @@ -0,0 +1,953 @@ +/// +/// 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 } from '@shared/models/widget-settings.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { + DataToValueType, + GetValueAction, + GetValueSettings, + SetValueAction, + SetValueSettings, + ValueToDataType +} from '@shared/models/action-widget-settings.models'; +import { Circle, Effect, Element, G, Gradient, Runner, Svg, Text, Timeline } from '@svgdotjs/svg.js'; +import '@svgdotjs/svg.filter.js'; +import tinycolor from 'tinycolor2'; +import { WidgetContext } from '@home/models/widget-component.models'; + +export enum PowerButtonLayout { + default = 'default', + simplified = 'simplified', + outlined = 'outlined', + default_volume = 'default_volume', + simplified_volume = 'simplified_volume', + outlined_volume = 'outlined_volume' +} + +export const powerButtonLayouts = Object.keys(PowerButtonLayout) as PowerButtonLayout[]; + +export const powerButtonLayoutTranslations = new Map( + [ + [PowerButtonLayout.default, 'widgets.power-button.layout-default'], + [PowerButtonLayout.simplified, 'widgets.power-button.layout-simplified'], + [PowerButtonLayout.outlined, 'widgets.power-button.layout-outlined'], + [PowerButtonLayout.default_volume, 'widgets.power-button.layout-default-volume'], + [PowerButtonLayout.simplified_volume, 'widgets.power-button.layout-simplified-volume'], + [PowerButtonLayout.outlined_volume, 'widgets.power-button.layout-outlined-volume'] + ] +); + +export const powerButtonLayoutImages = new Map( + [ + [PowerButtonLayout.default, 'assets/widget/power-button/default-layout.svg'], + [PowerButtonLayout.simplified, 'assets/widget/power-button/simplified-layout.svg'], + [PowerButtonLayout.outlined, 'assets/widget/power-button/outlined-layout.svg'], + [PowerButtonLayout.default_volume, 'assets/widget/power-button/default-volume-layout.svg'], + [PowerButtonLayout.simplified_volume, 'assets/widget/power-button/simplified-volume-layout.svg'], + [PowerButtonLayout.outlined_volume, 'assets/widget/power-button/outlined-volume-layout.svg'] + ] +); + +export interface PowerButtonWidgetSettings { + initialState: GetValueSettings; + disabledState: GetValueSettings; + onUpdateState: SetValueSettings; + offUpdateState: SetValueSettings; + layout: PowerButtonLayout; + mainColorOn: string; + backgroundColorOn: string; + mainColorOff: string; + backgroundColorOff: string; + mainColorDisabled: string; + backgroundColorDisabled: string; + background: BackgroundSettings; +} + +export const powerButtonDefaultSettings: PowerButtonWidgetSettings = { + initialState: { + action: GetValueAction.EXECUTE_RPC, + defaultValue: false, + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }, + disabledState: { + action: GetValueAction.DO_NOTHING, + defaultValue: false, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }, + onUpdateState: { + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: ValueToDataType.CONSTANT, + constantValue: true, + valueToDataFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' + } + }, + offUpdateState: { + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: ValueToDataType.CONSTANT, + constantValue: false, + valueToDataFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */ \n return value;' + } + }, + layout: PowerButtonLayout.default, + mainColorOn: '#3F52DD', + backgroundColorOn: '#FFFFFF', + mainColorOff: '#A2A2A2', + backgroundColorOff: '#FFFFFF', + mainColorDisabled: 'rgba(0,0,0,0.12)', + backgroundColorDisabled: '#FFFFFF', + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + } +}; + +interface PowerButtonColor { + hex: string; + opacity: number; +} + +type PowerButtonState = 'on' | 'off' | 'disabled'; + +interface PowerButtonColorState { + mainColor: PowerButtonColor; + backgroundColor: PowerButtonColor; +} + +type PowerButtonShapeColors = Record; + +const createPowerButtonShapeColors = (settings: PowerButtonWidgetSettings): PowerButtonShapeColors => { + const mainColorOn = tinycolor(settings.mainColorOn); + const backgroundColorOn = tinycolor(settings.backgroundColorOn); + const mainColorOff = tinycolor(settings.mainColorOff); + const backgroundColorOff = tinycolor(settings.backgroundColorOff); + const mainColorDisabled = tinycolor(settings.mainColorDisabled); + const backgroundColorDisabled = tinycolor(settings.backgroundColorDisabled); + return { + on: { + mainColor: {hex: mainColorOn.toHexString(), opacity: mainColorOn.getAlpha()}, + backgroundColor: {hex: backgroundColorOn.toHexString(), opacity: backgroundColorOn.getAlpha()}, + }, + off: { + mainColor: {hex: mainColorOff.toHexString(), opacity: mainColorOff.getAlpha()}, + backgroundColor: {hex: backgroundColorOff.toHexString(), opacity: backgroundColorOff.getAlpha()}, + }, + disabled: { + mainColor: {hex: mainColorDisabled.toHexString(), opacity: mainColorDisabled.getAlpha()}, + backgroundColor: {hex: backgroundColorDisabled.toHexString(), opacity: backgroundColorDisabled.getAlpha()}, + } + }; +}; + +export const powerButtonShapeSize = 110; +const cx = powerButtonShapeSize / 2; +const cy = powerButtonShapeSize / 2; + +const powerButtonAnimation = (element: Element): Runner => element.animate(200, 0, 'now'); + +export abstract class PowerButtonShape { + + static fromSettings(ctx: WidgetContext, + svgShape: Svg, + settings: PowerButtonWidgetSettings, + value: boolean, + disabled: boolean, + onClick: () => void): PowerButtonShape { + switch (settings.layout) { + case PowerButtonLayout.default: + return new DefaultPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + case PowerButtonLayout.simplified: + return new SimplifiedPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + case PowerButtonLayout.outlined: + return new OutlinedPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + case PowerButtonLayout.default_volume: + return new DefaultVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + case PowerButtonLayout.simplified_volume: + return new SimplifiedVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + case PowerButtonLayout.outlined_volume: + return new OutlinedVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); + } + } + + protected readonly colors: PowerButtonShapeColors; + protected readonly onLabel: string; + protected readonly offLabel: string; + + protected backgroundShape: Circle; + protected hoverShape: Circle; + protected hovered = false; + protected pressed = false; + protected forcePressed = false; + + protected constructor(protected widgetContext: WidgetContext, + protected svgShape: Svg, + protected settings: PowerButtonWidgetSettings, + protected value: boolean, + protected disabled: boolean, + protected onClick: () => void) { + this.colors = createPowerButtonShapeColors(this.settings); + this.onLabel = this.widgetContext.translate.instant('widgets.power-button.on-label').toUpperCase(); + this.offLabel = this.widgetContext.translate.instant('widgets.power-button.off-label').toUpperCase(); + this._drawShape(); + } + + public setValue(value: boolean) { + if (this.value !== value) { + this.value = value; + this._drawState(); + } + } + + public setDisabled(disabled: boolean) { + if (this.disabled !== disabled) { + this.disabled = disabled; + this._drawState(); + } + } + + public setPressed(pressed: boolean) { + if (this.forcePressed !== pressed) { + this.forcePressed = pressed; + if (this.forcePressed && !this.pressed) { + this.onPressStart(); + } else if (!this.forcePressed && !this.pressed) { + this.onPressEnd(); + } + } + } + + private _drawShape() { + + this.backgroundShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + + this.drawShape(); + + this.hoverShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy).addClass('tb-hover-circle') + .fill({color: '#000000', opacity: 0}); + this.hoverShape.on('mouseover', () => { + this.hovered = true; + if (!this.disabled) { + this.hoverShape.timeline().finish(); + this.hoverShape.animate(200).attr({'fill-opacity': 0.06}); + } + }); + this.hoverShape.on('mouseout', () => { + this.hovered = false; + this.hoverShape.timeline().finish(); + this.hoverShape.animate(200).attr({'fill-opacity': 0}); + this._cancelPressed(); + }); + this.hoverShape.on('touchmove', (event: TouchEvent) => { + const touch = event.touches[0]; + const element = document.elementFromPoint(touch.pageX,touch.pageY); + if (this.hoverShape.node !== element) { + this._cancelPressed(); + } + }); + this.hoverShape.on('touchcancel', () => { + this._cancelPressed(); + }); + this.hoverShape.on('mousedown touchstart', (event: Event) => { + if (event.type === 'mousedown') { + if ((event as MouseEvent).button !== 0) { + return; + } + } + if (!this.disabled && !this.pressed) { + this.pressed = true; + if (!this.forcePressed) { + this.onPressStart(); + } + } + }); + this.hoverShape.on('mouseup touchend touchcancel', () => { + if (this.pressed && !this.disabled) { + this.onClick(); + } + this._cancelPressed(); + }); + this._drawState(); + } + + private _cancelPressed() { + if (this.pressed) { + this.pressed = false; + if (!this.forcePressed) { + this.onPressEnd(); + } + } + } + + private _drawState() { + let colorState: PowerButtonColorState; + if (this.disabled) { + colorState = this.colors.disabled; + } else { + colorState = this.value ? this.colors.on : this.colors.off; + } + this.drawBackgroundState(colorState.backgroundColor); + this.drawColorState(colorState.mainColor); + if (this.value) { + this.drawOn(); + } else { + this.drawOff(); + } + if (this.disabled) { + this.hoverShape.timeline().finish(); + this.hoverShape.attr({'fill-opacity': 0}); + } else if (this.hovered) { + this.hoverShape.timeline().finish(); + this.hoverShape.animate(200).attr({'fill-opacity': 0.06}); + } + } + + private drawBackgroundState(backgroundColor: PowerButtonColor) { + this.backgroundShape.attr({ fill: backgroundColor.hex, 'fill-opacity': backgroundColor.opacity}); + } + + protected drawShape() {} + + protected drawColorState(_mainColor: PowerButtonColor) {} + + protected drawOff() {} + + protected drawOn() {} + + protected onPressStart() {} + + protected onPressEnd() {} + + protected createMask(shape: Element, maskElements: Element[]) { + const mask = + this.svgShape.mask().add(this.svgShape.rect().width('100%').height('100%').fill('#fff')); + maskElements.forEach(e => { + mask.add(e.fill('#000').attr({'fill-opacity': 1})); + }); + shape.maskWith(mask); + } + + protected createOnLabel(fontWeight = '500'): Text { + return this.createLabel(this.onLabel, fontWeight); + } + + protected createOffLabel(fontWeight = '500'): Text { + return this.createLabel(this.offLabel, fontWeight); + } + + private createLabel(text: string, fontWeight = '500'): Text { + return this.svgShape.text(text).font({ + family: 'Roboto', + weight: fontWeight, + style: 'normal', + size: '22px' + }).attr({x: '50%', y: '50%', 'text-anchor': 'middle', 'dominant-baseline': 'middle'}); + } + +} + +class InnerShadowCircle { + + private shadowCircle: Circle; + private blurEffect: Effect; + private offsetEffect: Effect; + private floodEffect: Effect; + + constructor(private svgShape: Svg, + private diameter: number, + private centerX: number, + private centerY: number, + private blur = 6, + private shadowOpacity = 0.6, + private dx = 0, + private dy = 0, + private shadowColor = '#000') { + + this.shadowCircle = this.svgShape.circle(this.diameter).center(this.centerX, this.centerY) + .fill({color: '#fff', opacity: 1}).stroke({width: 0}); + + this.shadowCircle.filterWith(add => { + add.x('-50%').y('-50%').width('200%').height('200%'); + let effect: Effect = add.componentTransfer(components => { + components.funcA({ type: 'table', tableValues: '1 0' }); + }).in(add.$fill); + effect = effect.gaussianBlur(this.blur, this.blur).attr({stdDeviation: this.blur}); + this.blurEffect = effect; + effect = effect.offset(this.dx, this.dy); + this.offsetEffect = effect; + effect = effect.flood(this.shadowColor, this.shadowOpacity); + this.floodEffect = effect; + effect = effect.composite(this.offsetEffect, 'in'); + effect.composite(add.$sourceAlpha, 'in'); + add.merge(m => { + m.mergeNode(add.$fill); + m.mergeNode(); + }); + }); + } + + public timeline(tl: Timeline): void { + this.blurEffect.timeline(tl); + this.offsetEffect.timeline(tl); + this.floodEffect.timeline(tl); + } + + public animate(blur: number, opacity: number, dx = 0, dy = 0): Runner { + powerButtonAnimation(this.blurEffect).attr({stdDeviation: blur}); + powerButtonAnimation(this.offsetEffect).attr({dx, dy}); + return powerButtonAnimation(this.floodEffect).attr({'flood-opacity': opacity}); + } + + public animateRestore(): Runner { + return this.animate(this.blur, this.shadowOpacity, this.dx, this.dy); + } + + public show(): void { + this.shadowCircle.show(); + } + + public hide(): void { + this.shadowCircle.hide(); + } + +} + +class DefaultPowerButtonShape extends PowerButtonShape { + + private outerBorder: Circle; + private outerBorderMask: Circle; + private offLabelShape: Text; + private onCircleShape: Circle; + private onLabelShape: Text; + private pressedShadow: InnerShadowCircle; + private pressedTimeline: Timeline; + private centerGroup: G; + + protected drawShape() { + this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy); + this.createMask(this.outerBorder, [this.outerBorderMask]); + this.centerGroup = this.svgShape.group(); + this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); + this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 20) + .center(cx, cy); + this.onLabelShape = this.createOnLabel(); + this.createMask(this.onCircleShape, [this.onLabelShape]); + this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 20, cx, cy, 0, 0); + + this.pressedTimeline = new Timeline(); + this.centerGroup.timeline(this.pressedTimeline); + this.onLabelShape.timeline(this.pressedTimeline); + this.pressedShadow.timeline(this.pressedTimeline); + } + + protected drawColorState(mainColor: PowerButtonColor) { + this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected drawOff() { + this.outerBorderMask.radius((powerButtonShapeSize - 20)/2); + this.onCircleShape.hide(); + this.centerGroup.show(); + } + + protected drawOn() { + this.outerBorderMask.radius((powerButtonShapeSize - 2)/2); + this.centerGroup.hide(); + this.onCircleShape.show(); + } + + protected onPressStart() { + this.pressedTimeline.finish(); + const pressedScale = 0.75; + powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); + powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale}); + this.pressedShadow.animate(6, 0.6); + } + + protected onPressEnd() { + this.pressedTimeline.finish(); + powerButtonAnimation(this.centerGroup).transform({scale: 1}); + powerButtonAnimation(this.onLabelShape).transform({scale: 1}); + this.pressedShadow.animateRestore(); + } + +} + +class SimplifiedPowerButtonShape extends PowerButtonShape { + + private outerBorder: Circle; + private outerBorderMask: Circle; + private onCircleShape: Circle; + private offLabelShape: Text; + private onLabelShape: Text; + private pressedShadow: InnerShadowCircle; + private pressedTimeline: Timeline; + private centerGroup: G; + + protected drawShape() { + this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 4).center(cx, cy); + this.createMask(this.outerBorder, [this.outerBorderMask]); + this.centerGroup = this.svgShape.group(); + this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); + this.onCircleShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy); + this.onLabelShape = this.createOnLabel(); + this.createMask(this.onCircleShape, [this.onLabelShape]); + this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 0, 0); + + this.pressedTimeline = new Timeline(); + this.centerGroup.timeline(this.pressedTimeline); + this.onLabelShape.timeline(this.pressedTimeline); + this.pressedShadow.timeline(this.pressedTimeline); + } + + protected drawColorState(mainColor: PowerButtonColor) { + this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected drawOff() { + this.onCircleShape.hide(); + this.outerBorder.show(); + this.centerGroup.show(); + } + + protected drawOn() { + this.centerGroup.hide(); + this.outerBorder.hide(); + this.onCircleShape.show(); + } + + protected onPressStart() { + this.pressedTimeline.finish(); + const pressedScale = 0.75; + powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); + powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale}); + this.pressedShadow.animate(6, 0.6); + } + + protected onPressEnd() { + this.pressedTimeline.finish(); + powerButtonAnimation(this.centerGroup).transform({scale: 1}); + powerButtonAnimation(this.onLabelShape).transform({scale: 1}); + this.pressedShadow.animateRestore(); + } +} + +class OutlinedPowerButtonShape extends PowerButtonShape { + private outerBorder: Circle; + private outerBorderMask: Circle; + private innerBorder: Circle; + private innerBorderMask: Circle; + private offLabelShape: Text; + private onCircleShape: Circle; + private onLabelShape: Text; + private pressedShadow: InnerShadowCircle; + private pressedTimeline: Timeline; + private centerGroup: G; + private onCenterGroup: G; + + protected drawShape() { + this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 2).center(cx, cy); + this.createMask(this.outerBorder, [this.outerBorderMask]); + this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy); + this.createMask(this.innerBorder, [this.innerBorderMask]); + this.centerGroup = this.svgShape.group(); + this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); + this.onCenterGroup = this.svgShape.group(); + this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 28).center(cx, cy) + .addTo(this.onCenterGroup); + this.onLabelShape = this.createOnLabel(); + this.createMask(this.onCircleShape, [this.onLabelShape]); + this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 24, cx, cy, 0, 0); + + this.pressedTimeline = new Timeline(); + this.centerGroup.timeline(this.pressedTimeline); + this.onCenterGroup.timeline(this.pressedTimeline); + this.onLabelShape.timeline(this.pressedTimeline); + this.pressedShadow.timeline(this.pressedTimeline); + } + + protected drawColorState(mainColor: PowerButtonColor) { + this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.innerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected drawOff() { + this.onCenterGroup.hide(); + this.centerGroup.show(); + } + + protected drawOn() { + this.centerGroup.hide(); + this.onCenterGroup.show(); + } + + protected onPressStart() { + this.pressedTimeline.finish(); + const pressedScale = 0.75; + powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); + powerButtonAnimation(this.onCenterGroup).transform({scale: 0.98}); + powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale / 0.98}); + this.pressedShadow.animate(6, 0.6); + } + + protected onPressEnd() { + this.pressedTimeline.finish(); + powerButtonAnimation(this.centerGroup).transform({scale: 1}); + powerButtonAnimation(this.onCenterGroup).transform({scale: 1}); + powerButtonAnimation(this.onLabelShape).transform({scale: 1}); + this.pressedShadow.animateRestore(); + } +} + +class DefaultVolumePowerButtonShape extends PowerButtonShape { + private outerBorder: Circle; + private outerBorderMask: Circle; + private outerBorderGradient: Gradient; + private innerBorder: Circle; + private innerBorderMask: Circle; + private innerBorderGradient: Gradient; + private innerShadow: InnerShadowCircle; + //private innerShadowGradient: Gradient; + //private innerShadowGradientStop: Stop; + private offLabelShape: Text; + private onCircleShape: Circle; + private onLabelShape: Text; + private pressedTimeline: Timeline; + private centerGroup: G; + + protected drawShape() { + this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy); + this.createMask(this.outerBorder, [this.outerBorderMask]); + this.outerBorderGradient = this.svgShape.gradient('linear', (add) => { + add.stop(0, '#CCCCCC', 1); + add.stop(1, '#FFFFFF', 1); + }).from(0.268, 0.92).to(0.832, 0.1188); + this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy); + this.createMask(this.innerBorder, [this.innerBorderMask]); + this.innerBorderGradient = this.svgShape.gradient('linear', (add) => { + add.stop(0, '#CCCCCC', 1); + add.stop(1, '#FFFFFF', 1); + }).from(0.832, 0.1188).to(0.268, 0.92); + this.centerGroup = this.svgShape.group(); + this.offLabelShape = this.createOffLabel('400').addTo(this.centerGroup); + this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy); + this.onLabelShape = this.createOnLabel('400'); + this.createMask(this.onCircleShape, [this.onLabelShape]); + this.innerShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 24, cx, cy, 3, 0.3); + + this.pressedTimeline = new Timeline(); + this.centerGroup.timeline(this.pressedTimeline); + this.onLabelShape.timeline(this.pressedTimeline); + this.innerShadow.timeline(this.pressedTimeline); + } + + protected drawColorState(mainColor: PowerButtonColor){ + if (this.disabled) { + this.backgroundShape.removeClass('tb-small-shadow'); + if (!this.forcePressed) { + this.innerShadow.hide(); + } + this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.innerBorder.attr({fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } else { + this.backgroundShape.addClass('tb-small-shadow'); + this.innerShadow.show(); + this.outerBorder.fill(this.outerBorderGradient); + this.outerBorder.attr({ 'fill-opacity': 1 }); + this.innerBorder.fill(this.innerBorderGradient); + this.innerBorder.attr({ 'fill-opacity': 1 }); + } + this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected drawOff() { + this.onCircleShape.hide(); + this.centerGroup.show(); + this.innerBorder.show(); + } + + protected drawOn() { + if (this.disabled) { + this.innerBorder.hide(); + } else { + this.innerBorder.show(); + } + this.centerGroup.hide(); + this.onCircleShape.show(); + } + + protected onPressStart() { + this.pressedTimeline.finish(); + this.innerShadow.show(); + const pressedScale = 0.75; + powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); + powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale}); + this.innerShadow.animate(6, 0.6); + } + + protected onPressEnd() { + this.pressedTimeline.finish(); + powerButtonAnimation(this.centerGroup).transform({scale: 1}); + powerButtonAnimation(this.onLabelShape).transform({scale: 1}); + this.innerShadow.animateRestore().after(() => { + if (this.disabled) { + this.innerShadow.hide(); + } + }); + } + +} + +class SimplifiedVolumePowerButtonShape extends PowerButtonShape { + + private outerBorder: Circle; + private outerBorderMask: Circle; + private offLabelShape: Text; + private onLabelShape: Text; + private innerShadow: InnerShadowCircle; + private pressedShadow: InnerShadowCircle; + private pressedTimeline: Timeline; + private centerGroup: G; + private onCenterGroup: G; + + + protected drawShape() { + this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) + .fill({color: '#FAFAFA', opacity: 1}).stroke({width: 0}); + this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 4).center(cx, cy); + this.createMask(this.outerBorder, [this.outerBorderMask]); + this.centerGroup = this.svgShape.group(); + this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); + this.onCenterGroup = this.svgShape.group(); + this.onLabelShape = this.createOnLabel().addTo(this.onCenterGroup); + this.innerShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 3, 0.3); + this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 0, 0); + this.pressedTimeline = new Timeline(); + this.centerGroup.timeline(this.pressedTimeline); + this.onCenterGroup.timeline(this.pressedTimeline); + this.pressedShadow.timeline(this.pressedTimeline); + } + + protected drawColorState(mainColor: PowerButtonColor){ + this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected drawOff() { + if (!this.pressed) { + this.backgroundShape.addClass('tb-shadow'); + } + this.innerShadow.hide(); + this.onCenterGroup.hide(); + this.centerGroup.show(); + } + + protected drawOn() { + this.backgroundShape.removeClass('tb-shadow'); + this.centerGroup.hide(); + this.onCenterGroup.show(); + this.innerShadow.show(); + } + + protected onPressStart() { + this.pressedTimeline.finish(); + const pressedScale = 0.75; + if (!this.value) { + this.backgroundShape.removeClass('tb-shadow'); + } + powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); + powerButtonAnimation(this.onCenterGroup).transform({scale: pressedScale}); + this.pressedShadow.animate(8, 0.4); + } + + protected onPressEnd() { + this.pressedTimeline.finish(); + powerButtonAnimation(this.centerGroup).transform({scale: 1}); + powerButtonAnimation(this.onCenterGroup).transform({scale: 1}); + this.pressedShadow.animateRestore().after(() => { + if (!this.value) { + this.backgroundShape.addClass('tb-shadow'); + } + }); + } +} + +class OutlinedVolumePowerButtonShape extends PowerButtonShape { + private outerBorder: Circle; + private outerBorderMask: Circle; + private outerBorderGradient: Gradient; + private innerBorder: Circle; + private innerBorderMask: Circle; + private offLabelShape: Text; + private onCircleShape: Circle; + private onLabelShape: Text; + private pressedShadow: InnerShadowCircle; + private pressedTimeline: Timeline; + private centerGroup: G; + private onCenterGroup: G; + + protected drawShape() { + this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy); + this.createMask(this.outerBorder, [this.outerBorderMask]); + this.outerBorderGradient = this.svgShape.gradient('linear', (add) => { + add.stop(0, '#CCCCCC', 1); + add.stop(1, '#FFFFFF', 1); + }).from(0.268, 0.92).to(0.832, 0.1188); + this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy) + .fill({opacity: 0}).stroke({width: 0}); + this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 30).center(cx, cy); + this.createMask(this.innerBorder, [this.innerBorderMask]); + this.centerGroup = this.svgShape.group(); + this.offLabelShape = this.createOffLabel('800').addTo(this.centerGroup); + this.onCenterGroup = this.svgShape.group(); + this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 30).center(cx, cy) + .addTo(this.onCenterGroup); + this.onLabelShape = this.createOnLabel('800'); + this.createMask(this.onCircleShape, [this.onLabelShape]); + this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 30, cx, cy, 0, 0); + this.backgroundShape.addClass('tb-small-shadow'); + + this.pressedTimeline = new Timeline(); + this.centerGroup.timeline(this.pressedTimeline); + this.onCenterGroup.timeline(this.pressedTimeline); + this.onLabelShape.timeline(this.pressedTimeline); + this.pressedShadow.timeline(this.pressedTimeline); + } + + protected drawColorState(mainColor: PowerButtonColor){ + if (this.disabled) { + this.outerBorder.attr({ fill: '#000000', 'fill-opacity': 0.03}); + } else { + this.outerBorder.fill(this.outerBorderGradient); + this.outerBorder.attr({ 'fill-opacity': 1 }); + } + this.innerBorder.attr({fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + } + + protected drawOff() { + this.onCenterGroup.hide(); + this.centerGroup.show(); + this.innerBorder.show(); + } + + protected drawOn() { + this.innerBorder.hide(); + this.centerGroup.hide(); + this.onCenterGroup.show(); + } + + protected onPressStart() { + this.pressedTimeline.finish(); + const pressedScale = 0.75; + powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); + powerButtonAnimation(this.onCenterGroup).transform({scale: 0.98}); + powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale / 0.98}); + this.pressedShadow.animate(6, 0.6); + } + + protected onPressEnd() { + this.pressedTimeline.finish(); + powerButtonAnimation(this.centerGroup).transform({scale: 1}); + powerButtonAnimation(this.onCenterGroup).transform({scale: 1}); + powerButtonAnimation(this.onLabelShape).transform({scale: 1}); + this.pressedShadow.animateRestore(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html index 3f538f77f7..4f618177a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html @@ -1,6 +1,6 @@ +
+
+
+ +
+
+
+ {{ icon }} +
{{ label$ | async }}
+
+
+
{{ offLabel }}
+ + +
{{ onLabel }}
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.scss new file mode 100644 index 0000000000..27207e5f9d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.scss @@ -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. + */ +$tumblerColorOn: var(--tb-single-switch-tumbler-color-on, #fff); +$tumblerColorOff: var(--tb-single-switch-tumbler-color-off, #fff); +$tumblerColorDisabled: var(--tb-single-switch-tumbler-color-disabled, #fff); + +$switchColorOn: var(--tb-single-switch-color-on, #5469FF); +$switchColorOff: var(--tb-single-switch-color-off, rgba(84, 105, 255, 0.30)); +$switchColorDisabled: var(--tb-single-switch-color-disabled, #D5D7E5); + +.tb-single-switch-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 18px 24px; + &.auto-scale { + padding: 0; + } + > div:not(.tb-single-switch-overlay), > tb-icon { + z-index: 1; + } + .tb-single-switch-overlay { + position: absolute; + inset: 12px; + } + > div.tb-single-switch-title-panel { + position: absolute; + top: 12px; + left: 12px; + right: 12px; + z-index: 2; + } + .tb-single-switch-content { + width: 100%; + height: 100%; + position: relative; + display: flex; + align-items: center; + gap: 8px; + &.right { + flex-direction: row; + justify-content: space-between; + } + &.no-label.right { + justify-content: flex-end; + } + &.left { + flex-direction: row-reverse; + justify-content: flex-end; + } + &.centered { + flex-direction: row; + justify-content: center; + } + .tb-single-switch-label-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + .tb-single-switch-label { + white-space: nowrap; + } + } + &:not(.auto-scale) { + .tb-single-switch-label-row { + overflow: hidden; + .tb-single-switch-label { + overflow: hidden; + text-overflow: ellipsis; + } + } + } + .tb-single-switch-slide-toggle-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + .mat-mdc-slide-toggle { + &.tb-single-switch-toggle { + .mdc-switch { + width: 42px; + height: 22px; + .mdc-switch__track { + height: 22px; + border-radius: 11px; + &::after, &::before { + border: none; + } + } + .mdc-switch__handle-track { + width: calc(100% - 22px); + left: 2px; + } + .mdc-switch__handle { + border-radius: 9px; + width: 18px; + height: 18px; + } + .mdc-switch__ripple { + width: 36px; + height: 36px; + } + .mdc-switch__icons { + display: none; + } + &:disabled { + .mdc-switch__track { + opacity: 1; + &::after, &::before { + background: $switchColorDisabled; + } + } + .mdc-switch__handle::after { + opacity: 1; + background: $tumblerColorDisabled; + } + } + &:enabled, &:enabled:hover { + .mdc-switch__track { + &::after { + background: $switchColorOn; + } + &::before { + background: $switchColorOff; + } + } + &.mdc-switch--selected { + .mdc-switch__handle::after { + background: $tumblerColorOn; + } + .mdc-switch__ripple::after { + background-color: $switchColorOn; + } + } + &.mdc-switch--unselected { + .mdc-switch__handle::after { + background: $tumblerColorOff; + } + .mdc-switch__ripple::after { + background-color: $switchColorOff; + } + } + } + } + .mdc-form-field > label { + display: none; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts new file mode 100644 index 0000000000..2c15634893 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts @@ -0,0 +1,256 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + OnInit, + Renderer2, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; +import { + singleSwitchDefaultSettings, + SingleSwitchLayout, + SingleSwitchWidgetSettings +} from '@home/components/widget/lib/rpc/single-switch-widget.models'; +import { + backgroundStyle, + ComponentStyle, + iconStyle, + overlayStyle, + textStyle +} from '@shared/models/widget-settings.models'; +import { Observable } from 'rxjs'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { ValueType } from '@shared/models/constants'; +import { UtilsService } from '@core/services/utils.service'; + +const initialSwitchHeight = 60; +const horizontalLayoutPadding = 48; +const verticalLayoutPadding = 36; + +@Component({ + selector: 'tb-single-switch-widget', + templateUrl: './single-switch-widget.component.html', + styleUrls: ['../action/action-widget.scss', './single-switch-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SingleSwitchWidgetComponent extends + BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { + + @ViewChild('singleSwitchPanel', {static: false}) + singleSwitchPanel: ElementRef; + + @ViewChild('singleSwitchContent', {static: false}) + singleSwitchContent: ElementRef; + + @ViewChild('singleSwitchLabelRow', {static: false}) + singleSwitchLabelRow: ElementRef; + + @ViewChild('singleSwitchToggleRow', {static: false}) + singleSwitchToggleRow: ElementRef; + + settings: SingleSwitchWidgetSettings; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + overlayInset = '12px'; + + value = false; + disabled = false; + + layout: SingleSwitchLayout; + + showIcon = false; + icon = ''; + iconStyle: ComponentStyle = {}; + + showLabel = true; + label$: Observable; + labelStyle: ComponentStyle = {}; + + showOnLabel = false; + onLabel = ''; + onLabelStyle: ComponentStyle = {}; + + showOffLabel = false; + offLabel = ''; + offLabelStyle: ComponentStyle = {}; + + disabledColor = 'rgba(0, 0, 0, 0.38)'; + + autoScale = false; + + private panelResize$: ResizeObserver; + + private onValueSetter: ValueSetter; + private offValueSetter: ValueSetter; + + private singleSwitchCssClass: string; + + constructor(protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + private renderer: Renderer2, + private utils: UtilsService, + protected cd: ChangeDetectorRef, + private elementRef: ElementRef) { + super(cd); + } + + ngOnInit(): void { + super.ngOnInit(); + this.settings = {...singleSwitchDefaultSettings, ...this.ctx.settings}; + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + + this.layout = this.settings.layout; + + this.autoScale = this.settings.autoScale; + + this.showLabel = this.settings.showLabel; + this.label$ = this.ctx.registerLabelPattern(this.settings.label, this.label$); + this.labelStyle = textStyle(this.settings.labelFont); + + this.showIcon = this.settings.showIcon; + this.icon = this.settings.icon; + this.iconStyle = iconStyle(this.settings.iconSize, this.settings.iconSizeUnit ); + + this.showOnLabel = this.settings.showOnLabel; + this.onLabel = this.settings.onLabel; + this.onLabelStyle = textStyle(this.settings.onLabelFont); + + this.showOffLabel = this.settings.showOffLabel; + this.offLabel = this.settings.offLabel; + this.offLabelStyle = textStyle(this.settings.offLabelFont); + const switchVariablesCss = `.tb-single-switch-panel {\n`+ + `--tb-single-switch-tumbler-color-on: ${this.settings.tumblerColorOn};\n`+ + `--tb-single-switch-tumbler-color-off: ${this.settings.tumblerColorOff};\n`+ + `--tb-single-switch-tumbler-color-disabled: ${this.settings.tumblerColorDisabled};\n`+ + `--tb-single-switch-color-on: ${this.settings.switchColorOn};\n`+ + `--tb-single-switch-color-off: ${this.settings.switchColorOff};\n`+ + `--tb-single-switch-color-disabled: ${this.settings.switchColorDisabled};\n`+ + `}`; + this.singleSwitchCssClass = + this.utils.applyCssToElement(this.renderer, this.elementRef.nativeElement, 'tb-single-switch', switchVariablesCss); + + const getInitialStateSettings = + {...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.initial-state')}; + this.createValueGetter(getInitialStateSettings, ValueType.BOOLEAN, { + next: (value) => this.onValue(value) + }); + + const disabledStateSettings = + {...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.disabled-state')}; + this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, { + next: (value) => this.onDisabled(value) + }); + + const onUpdateStateSettings = {...this.settings.onUpdateState, + actionLabel: this.ctx.translate.instant('widgets.rpc-state.turn-on')}; + this.onValueSetter = this.createValueSetter(onUpdateStateSettings); + + const offUpdateStateSettings = {...this.settings.offUpdateState, + actionLabel: this.ctx.translate.instant('widgets.rpc-state.turn-off')}; + this.offValueSetter = this.createValueSetter(offUpdateStateSettings); + } + + ngAfterViewInit(): void { + if (this.autoScale) { + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'overflow', 'visible'); + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'position', 'absolute'); + this.panelResize$ = new ResizeObserver(() => { + this.onResize(); + }); + this.panelResize$.observe(this.singleSwitchPanel.nativeElement); + if (this.showLabel) { + this.panelResize$.observe(this.singleSwitchLabelRow.nativeElement); + } + this.onResize(); + } + super.ngAfterViewInit(); + } + + ngOnDestroy() { + if (this.panelResize$) { + this.panelResize$.disconnect(); + } + if (this.singleSwitchCssClass) { + this.utils.clearCssElement(this.renderer, this.singleSwitchCssClass); + } + super.ngOnDestroy(); + } + + public onInit() { + super.onInit(); + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + + public onToggleChange(event: MouseEvent) { + if (!this.ctx.isEdit && !this.ctx.isPreview) { + event.preventDefault(); + const targetValue = this.value; + const targetSetter = targetValue ? this.onValueSetter : this.offValueSetter; + this.updateValue(targetSetter, targetValue, { + next: () => this.onValue(targetValue), + error: () => this.onValue(!targetValue) + }); + } + } + + private onValue(value: boolean): void { + this.value = !!value; + this.cd.markForCheck(); + } + + private onDisabled(value: boolean): void { + this.disabled = !!value; + this.cd.markForCheck(); + } + + private onResize() { + const height = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height; + const switchScale = height / initialSwitchHeight; + const paddingScale = Math.min(switchScale, 1); + const panelWidth = this.singleSwitchPanel.nativeElement.getBoundingClientRect().width - (horizontalLayoutPadding * paddingScale); + const panelHeight = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height - (verticalLayoutPadding * paddingScale); + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(1)`); + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'width', 'auto'); + let contentWidth = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().width; + let contentHeight = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().height; + if (this.showIcon || this.showLabel) { + contentWidth += (8 + this.singleSwitchLabelRow.nativeElement.getBoundingClientRect().width); + contentHeight = Math.max(contentHeight, this.singleSwitchLabelRow.nativeElement.getBoundingClientRect().height); + } + const maxScale = Math.max(1, switchScale); + const scale = Math.min(Math.min(panelWidth / contentWidth, panelHeight / contentHeight), maxScale); + const width = panelWidth / scale; + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'width', width + 'px'); + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(${scale})`); + this.overlayInset = (Math.floor(12 * paddingScale * 100) / 100) + 'px'; + this.cd.markForCheck(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts new file mode 100644 index 0000000000..94586c31de --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts @@ -0,0 +1,221 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BackgroundSettings, BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { + DataToValueType, + GetValueAction, + GetValueSettings, + SetValueAction, + SetValueSettings, + ValueToDataType +} from '@shared/models/action-widget-settings.models'; + +export enum SingleSwitchLayout { + right = 'right', + left = 'left', + centered = 'centered' +} + +export const singleSwitchLayouts = Object.keys(SingleSwitchLayout) as SingleSwitchLayout[]; + +export const singleSwitchLayoutTranslations = new Map( + [ + [SingleSwitchLayout.right, 'widgets.single-switch.layout-right'], + [SingleSwitchLayout.left, 'widgets.single-switch.layout-left'], + [SingleSwitchLayout.centered, 'widgets.single-switch.layout-centered'] + ] +); + +export const singleSwitchLayoutImages = new Map( + [ + [SingleSwitchLayout.right, 'assets/widget/single-switch/right-layout.svg'], + [SingleSwitchLayout.left, 'assets/widget/single-switch/left-layout.svg'], + [SingleSwitchLayout.centered, 'assets/widget/single-switch/centered-layout.svg'] + ] +); + +export interface SingleSwitchWidgetSettings { + initialState: GetValueSettings; + disabledState: GetValueSettings; + onUpdateState: SetValueSettings; + offUpdateState: SetValueSettings; + layout: SingleSwitchLayout; + autoScale: boolean; + showLabel: boolean; + label: string; + labelFont: Font; + labelColor: string; + showIcon: boolean; + icon: string; + iconSize: number; + iconSizeUnit: cssUnit; + iconColor: string; + switchColorOn: string; + switchColorOff: string; + switchColorDisabled: string; + tumblerColorOn: string; + tumblerColorOff: string; + tumblerColorDisabled: string; + showOnLabel: boolean; + onLabel: string; + onLabelFont: Font; + onLabelColor: string; + showOffLabel: boolean; + offLabel: string; + offLabelFont: Font; + offLabelColor: string; + background: BackgroundSettings; +} + +export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = { + initialState: { + action: GetValueAction.EXECUTE_RPC, + defaultValue: false, + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }, + disabledState: { + action: GetValueAction.DO_NOTHING, + defaultValue: false, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }, + onUpdateState: { + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: ValueToDataType.CONSTANT, + constantValue: true, + valueToDataFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' + } + }, + offUpdateState: { + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: ValueToDataType.CONSTANT, + constantValue: false, + valueToDataFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */ \n return value;' + } + }, + layout: SingleSwitchLayout.right, + autoScale: true, + showLabel: true, + label: 'Switch', + labelFont: { + family: 'Roboto', + size: 16, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '24px' + }, + labelColor: 'rgba(0, 0, 0, 0.76)', + showIcon: false, + icon: 'mdi:lightbulb-outline', + iconSize: 24, + iconSizeUnit: 'px', + iconColor: 'rgba(0, 0, 0, 0.76)', + switchColorOn: '#5469FF', + switchColorOff: 'rgba(84, 105, 255, 0.30)', + switchColorDisabled: '#D5D7E5', + tumblerColorOn: '#fff', + tumblerColorOff: '#fff', + tumblerColorDisabled: '#fff', + showOnLabel: false, + onLabel: 'On', + onLabelFont: { + family: 'Roboto', + size: 16, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '24px' + }, + onLabelColor: 'rgba(0, 0, 0, 0.38)', + showOffLabel: false, + offLabel: 'Off', + offLabelFont: { + family: 'Roboto', + size: 16, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '24px' + }, + offLabelColor: 'rgba(0, 0, 0, 0.38)', + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + } +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html new file mode 100644 index 0000000000..f15892dd52 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html @@ -0,0 +1,59 @@ + +
+
+
+ +
+
+
+
{{ valueText }}
+
+
+
+ {{ leftIcon }} +
+
+ + + +
+
+
{{ settings.tickMin }}
+
+
+
{{ settings.tickMax }}
+
+
+
+
+ {{ rightIcon }} +
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss new file mode 100644 index 0000000000..0d7115fccb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss @@ -0,0 +1,135 @@ +/** + * 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. + */ +$mainColor: var(--tb-slider-main-color, #5469FF); +$hoverRippleColor: var(--tb-slider-hover-ripple-color, rgba(84, 105, 255, 0.05)); +$focusRippleColor: var(--tb-slider-focus-ripple-color, rgba(84, 105, 255, 0.2)); +$backgroundColor: var(--tb-slider-background-color, #CCD2FF); +$tickMarksColor: var(--tb-slider-tick-marks-color, #5469FF); + +$mainColorDisabled: var(--tb-slider-main-color-disabled, #9BA2B0); +$backgroundColorDisabled: var(--tb-slider-background-color-disabled, #D5D7E5); + +.tb-slider-panel { + + .mat-mdc-slider.mat-primary.tb-slider { + --mdc-slider-active-track-color: #{$mainColor}; + --mdc-slider-handle-color: #{$mainColor}; + --mdc-slider-focus-handle-color: #{$mainColor}; + --mdc-slider-hover-handle-color: #{$mainColor}; + + --mdc-slider-with-tick-marks-inactive-container-color: #{$tickMarksColor}; + --mat-mdc-slider-ripple-color: #{$mainColor}; + + --mat-mdc-slider-hover-ripple-color: #{$hoverRippleColor}; + --mat-mdc-slider-focus-ripple-color: #{$focusRippleColor}; + + --mdc-slider-inactive-track-color: #{$backgroundColor}; + + --mdc-slider-disabled-active-track-color: #{$mainColorDisabled}; + --mdc-slider-disabled-handle-color: #{$mainColorDisabled}; + + --mdc-slider-disabled-inactive-track-color: #{$backgroundColorDisabled}; + --mdc-slider-with-tick-marks-disabled-container-color: #{$mainColorDisabled}; + + --mdc-slider-handle-width: 16px; + --mdc-slider-handle-height: 16px; + } + + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + padding: 20px 24px 24px 24px; + gap: 8px; + > div:not(.tb-slider-overlay), > tb-icon { + z-index: 1; + } + .tb-slider-overlay { + position: absolute; + inset: 12px; + } + div.tb-slider-title-panel { + z-index: 2; + } + .tb-slider-content { + flex: 1; + min-height: 0; + display: flex; + position: relative; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + .tb-slider-value-container { + min-height: 0; + } + .tb-slider-value { + white-space: nowrap; + } + .tb-slider-container { + align-self: stretch; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 8px; + &.tb-min-height { + height: 6px; + } + .tb-slider-column { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + .mat-mdc-slider.tb-slider { + margin: 0; + height: 6px; + min-height: 6px; + min-width: 0; + &.mdc-slider--disabled { + opacity: 1; + } + .mdc-slider__track--inactive { + opacity: 1; + } + .mdc-slider__tick-marks { + .mdc-slider__tick-mark--active { + display: none; + } + .mdc-slider__tick-mark--inactive { + opacity: 1; + } + } + .mdc-slider__thumb.mat-mdc-slider-visual-thumb { + top: -21px; + .mat-ripple { + overflow: visible; + } + } + .mdc-slider__value-indicator-text { + white-space: nowrap; + } + } + .tb-slider-ticks { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts new file mode 100644 index 0000000000..37b5319d44 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts @@ -0,0 +1,347 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + OnInit, + Renderer2, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; +import { + backgroundStyle, + ComponentStyle, + iconStyle, + overlayStyle, + textStyle +} from '@shared/models/widget-settings.models'; +import { Observable } from 'rxjs'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { ValueType } from '@shared/models/constants'; +import { UtilsService } from '@core/services/utils.service'; +import { + SliderLayout, + sliderWidgetDefaultSettings, + SliderWidgetSettings +} from '@home/components/widget/lib/rpc/slider-widget.models'; +import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils'; +import { WidgetComponent } from '@home/components/widget/widget.component'; +import tinycolor from 'tinycolor2'; + +@Component({ + selector: 'tb-slider-widget', + templateUrl: './slider-widget.component.html', + styleUrls: ['../action/action-widget.scss', './slider-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SliderWidgetComponent extends + BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { + + @ViewChild('sliderContent', {static: false}) + sliderContent: ElementRef; + + @ViewChild('sliderValueContainer', {static: false}) + sliderValueContainer: ElementRef; + + @ViewChild('sliderValue', {static: false}) + sliderValue: ElementRef; + + @ViewChild('sliderTickMinContainer', {static: false}) + sliderTickMinContainer: ElementRef; + + @ViewChild('sliderTickMin', {static: false}) + sliderTickMin: ElementRef; + + @ViewChild('sliderTickMaxContainer', {static: false}) + sliderTickMaxContainer: ElementRef; + + @ViewChild('sliderTickMax', {static: false}) + sliderTickMax: ElementRef; + + @ViewChild('leftSliderIconContainer', {static: false, read: ElementRef}) + leftSliderIconContainer: ElementRef; + + @ViewChild('leftSliderIcon', {static: false, read: ElementRef}) + leftSliderIcon: ElementRef; + + @ViewChild('rightSliderIconContainer', {static: false, read: ElementRef}) + rightSliderIconContainer: ElementRef; + + @ViewChild('rightSliderIcon', {static: false, read: ElementRef}) + rightSliderIcon: ElementRef; + + settings: SliderWidgetSettings; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + + value: number = null; + private prevValue: number = null; + + disabled = false; + + layout: SliderLayout; + + showValue = true; + valueText = 'N/A'; + valueStyle: ComponentStyle = {}; + + showLeftRightIcon = false; + leftIcon = ''; + leftIconStyle: ComponentStyle = {}; + rightIcon = ''; + rightIconStyle: ComponentStyle = {}; + + showTicks = true; + ticksStyle: ComponentStyle = {}; + + sliderStep: number = undefined; + + autoScale = false; + + showWidgetTitlePanel = this.widgetComponent.dashboardWidget.showWidgetTitlePanel; + + sliderValueText = this._sliderValueText.bind(this); + + private panelResize$: ResizeObserver; + + private valueSetter: ValueSetter; + + private sliderCssClass: string; + + constructor(protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + private renderer: Renderer2, + private utils: UtilsService, + private widgetComponent: WidgetComponent, + protected cd: ChangeDetectorRef, + private elementRef: ElementRef) { + super(cd); + } + + ngOnInit(): void { + super.ngOnInit(); + this.settings = {...sliderWidgetDefaultSettings, ...this.ctx.settings}; + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + + this.layout = this.settings.layout; + + this.autoScale = this.settings.autoScale; + + this.showValue = this.layout !== SliderLayout.simplified && this.settings.showValue; + this.valueStyle = textStyle(this.settings.valueFont); + this.valueStyle.color = this.settings.valueColor; + + this.showLeftRightIcon = this.layout === SliderLayout.extended; + if (this.showLeftRightIcon) { + this.leftIcon = this.settings.leftIcon; + this.leftIconStyle = iconStyle(this.settings.leftIconSize, this.settings.leftIconSizeUnit ); + this.rightIcon = this.settings.rightIcon; + this.rightIconStyle = iconStyle(this.settings.rightIconSize, this.settings.rightIconSizeUnit ); + if (!this.autoScale) { + const leftIconMargin = this.settings.leftIconSize / 2 + (this.settings.leftIconSizeUnit || 'px'); + this.leftIconStyle.marginTop = `calc(-${leftIconMargin} + 3px)`; + const rightIconMargin = this.settings.rightIconSize / 2 + (this.settings.rightIconSizeUnit || 'px'); + this.rightIconStyle.marginTop = `calc(-${rightIconMargin} + 3px)`; + } + } + + this.showTicks = this.settings.showTicks; + if (this.showTicks) { + this.ticksStyle = textStyle(this.settings.ticksFont); + this.ticksStyle.color = this.settings.ticksColor; + } + + if (this.settings.showTickMarks) { + const range = this.settings.tickMax - this.settings.tickMin; + this.sliderStep = range / (this.settings.tickMarksCount - 1); + } + + const mainColorInstance = tinycolor(this.settings.mainColor); + const hoverRippleColor = mainColorInstance.clone().setAlpha(mainColorInstance.getAlpha() * 0.05).toRgbString(); + const focusRippleColor = mainColorInstance.clone().setAlpha(mainColorInstance.getAlpha() * 0.2).toRgbString(); + + const sliderVariablesCss = `.tb-slider-panel {\n`+ + `--tb-slider-main-color: ${this.settings.mainColor};\n`+ + `--tb-slider-background-color: ${this.settings.backgroundColor};\n`+ + `--tb-slider-hover-ripple-color: ${hoverRippleColor};\n`+ + `--tb-slider-focus-ripple-color: ${focusRippleColor};\n`+ + `--tb-slider-tick-marks-color: ${this.settings.tickMarksColor};\n`+ + `--tb-slider-main-color-disabled: ${this.settings.mainColorDisabled};\n`+ + `--tb-slider-background-disabled: ${this.settings.backgroundColorDisabled};\n`+ + `}`; + this.sliderCssClass = + this.utils.applyCssToElement(this.renderer, this.elementRef.nativeElement, 'tb-slider', sliderVariablesCss); + + const getInitialStateSettings = + {...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.slider.initial-value')}; + this.createValueGetter(getInitialStateSettings, ValueType.INTEGER, { + next: (value) => this.onValue(value) + }); + + const disabledStateSettings = + {...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.disabled-state')}; + this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, { + next: (value) => this.onDisabled(value) + }); + + const valueChangeSettings = {...this.settings.valueChange, + actionLabel: this.ctx.translate.instant('widgets.slider.on-value-change')}; + this.valueSetter = this.createValueSetter(valueChangeSettings); + } + + ngAfterViewInit(): void { + if (this.autoScale) { + this.panelResize$ = new ResizeObserver(() => { + this.onResize(); + }); + this.panelResize$.observe(this.sliderContent.nativeElement); + if (this.showValue) { + this.panelResize$.observe(this.sliderValueContainer.nativeElement); + } + this.onResize(); + } + super.ngAfterViewInit(); + } + + ngOnDestroy() { + if (this.panelResize$) { + this.panelResize$.disconnect(); + } + if (this.sliderCssClass) { + this.utils.clearCssElement(this.renderer, this.sliderCssClass); + } + super.ngOnDestroy(); + } + + public onInit() { + super.onInit(); + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + + public onSliderChange() { + this.updateValueText(); + if (!this.ctx.isEdit && !this.ctx.isPreview) { + const prevValue = this.prevValue; + const targetValue = this.value; + this.updateValue(this.valueSetter, targetValue, { + next: () => this.onValue(targetValue), + error: () => this.onValue(prevValue) + }); + } + } + + private _sliderValueText(value: number): string { + return formatValue(value, this.settings.valueDecimals, this.settings.valueUnits, false); + } + + private onValue(value: number): void { + this.value = value; + this.prevValue = value; + this.updateValueText(); + this.cd.markForCheck(); + } + + private updateValueText() { + if (isDefinedAndNotNull(this.value) && isNumeric(this.value)) { + this.valueText = formatValue(this.value, this.settings.valueDecimals, this.settings.valueUnits, false); + } else { + this.valueText = 'N/A'; + } + } + + private onDisabled(value: boolean): void { + this.disabled = !!value; + this.cd.markForCheck(); + } + + private onResize() { + const panelWidth = this.sliderContent.nativeElement.getBoundingClientRect().width; + const panelHeight = this.sliderContent.nativeElement.getBoundingClientRect().height; + + if (this.showValue) { + this.resetScale(this.sliderValueContainer.nativeElement, this.sliderValue.nativeElement); + } + + if (this.showLeftRightIcon) { + this.resetScale(this.leftSliderIconContainer.nativeElement, this.leftSliderIcon.nativeElement); + this.resetScale(this.rightSliderIconContainer.nativeElement, this.rightSliderIcon.nativeElement); + } + + if (this.showTicks) { + this.resetScale(this.sliderTickMinContainer.nativeElement, this.sliderTickMin.nativeElement); + this.resetScale(this.sliderTickMaxContainer.nativeElement, this.sliderTickMax.nativeElement); + } + + let minAspect = 0.2; + let avgContentHeight = 35; + if (this.showTicks) { + minAspect += 0.1; + avgContentHeight += 20; + } + if (this.showValue) { + minAspect += 0.1; + avgContentHeight += 50; + } + const aspect = Math.min(panelHeight / panelWidth, minAspect); + const targetHeight = panelWidth * aspect; + const scale = targetHeight / avgContentHeight; + + if (this.showValue) { + this.updateScale(this.sliderValueContainer.nativeElement, this.sliderValue.nativeElement, scale); + } + if (this.showLeftRightIcon) { + const leftIconContainerRect = this.leftSliderIconContainer.nativeElement.getBoundingClientRect(); + const leftIconContainerMarginTop = -(leftIconContainerRect.width * scale) / 2 + 3; + this.renderer.setStyle(this.leftSliderIconContainer.nativeElement, 'marginTop', `${leftIconContainerMarginTop}px`); + this.updateScale(this.leftSliderIconContainer.nativeElement, this.leftSliderIcon.nativeElement, scale, true); + const rightIconContainerRect = this.rightSliderIconContainer.nativeElement.getBoundingClientRect(); + const rightIconContainerMarginTop = -(rightIconContainerRect.width * scale) / 2 + 3; + this.renderer.setStyle(this.rightSliderIconContainer.nativeElement, 'marginTop', `${rightIconContainerMarginTop}px`); + this.updateScale(this.rightSliderIconContainer.nativeElement, this.rightSliderIcon.nativeElement, scale, true); + } + if (this.showTicks) { + this.updateScale(this.sliderTickMinContainer.nativeElement, this.sliderTickMin.nativeElement, scale); + this.updateScale(this.sliderTickMaxContainer.nativeElement, this.sliderTickMax.nativeElement, scale); + } + } + + private resetScale(container: HTMLElement, element: HTMLElement): void { + this.renderer.setStyle(container, 'width', ''); + this.renderer.setStyle(container, 'height', ''); + this.renderer.setStyle(element, 'transform', ''); + } + + private updateScale(container: HTMLElement, element: HTMLElement, scale: number, sameHeight = false): void { + const rect = container.getBoundingClientRect(); + this.renderer.setStyle(container, 'width', `${rect.width * scale}px`); + this.renderer.setStyle(container, 'height', `${(sameHeight ? rect.width : rect.height) * scale}px`); + this.renderer.setStyle(element, 'transform', `scale(${scale})`); + this.renderer.setStyle(element, 'transform-origin', 'left top'); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.models.ts new file mode 100644 index 0000000000..f6daba5da7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.models.ts @@ -0,0 +1,196 @@ +/// +/// 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 { + DataToValueType, + GetValueAction, + GetValueSettings, + SetValueAction, + SetValueSettings, + ValueToDataType +} from '@shared/models/action-widget-settings.models'; +import { BackgroundSettings, BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; + +export enum SliderLayout { + default = 'default', + extended = 'extended', + simplified = 'simplified' +} + +export const sliderLayouts = Object.keys(SliderLayout) as SliderLayout[]; + +export const sliderLayoutTranslations = new Map( + [ + [SliderLayout.default, 'widgets.slider.layout-default'], + [SliderLayout.extended, 'widgets.slider.layout-extended'], + [SliderLayout.simplified, 'widgets.slider.layout-simplified'] + ] +); + +export const sliderLayoutImages = new Map( + [ + [SliderLayout.default, 'assets/widget/slider/default-layout.svg'], + [SliderLayout.extended, 'assets/widget/slider/extended-layout.svg'], + [SliderLayout.simplified, 'assets/widget/slider/simplified-layout.svg'] + ] +); + +export interface SliderWidgetSettings { + initialState: GetValueSettings; + disabledState: GetValueSettings; + valueChange: SetValueSettings; + layout: SliderLayout; + autoScale: boolean; + showValue: boolean; + valueUnits: string; + valueDecimals: number; + valueFont: Font; + valueColor: string; + showTicks: boolean; + tickMin: number; + tickMax: number; + ticksFont: Font; + ticksColor: string; + showTickMarks: boolean; + tickMarksCount: number; + tickMarksColor: string; + mainColor: string; + backgroundColor: string; + mainColorDisabled: string; + backgroundColorDisabled: string; + leftIcon: string; + leftIconSize: number; + leftIconSizeUnit: cssUnit; + leftIconColor: string; + rightIcon: string; + rightIconSize: number; + rightIconSizeUnit: cssUnit; + rightIconColor: string; + background: BackgroundSettings; +} + +export const sliderWidgetDefaultSettings: SliderWidgetSettings = { + initialState: { + action: GetValueAction.EXECUTE_RPC, + defaultValue: 0, + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return integer value */\nreturn data;' + } + }, + disabledState: { + action: GetValueAction.DO_NOTHING, + defaultValue: false, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }, + valueChange: { + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: ValueToDataType.VALUE, + constantValue: 0, + valueToDataFunction: '/* Convert input integer value to RPC parameters or attribute/time-series value */\nreturn value;' + } + }, + layout: SliderLayout.default, + autoScale: true, + showValue: true, + valueUnits: '%', + valueDecimals: 0, + valueFont: { + family: 'Roboto', + size: 36, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '36px' + }, + valueColor: 'rgba(0, 0, 0, 0.87)', + showTicks: true, + tickMin: 0, + tickMax: 100, + ticksFont: { + family: 'Roboto', + size: 11, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '16px' + }, + ticksColor: 'rgba(0,0,0,0.54)', + showTickMarks: true, + tickMarksCount: 11, + tickMarksColor: '#5469FF', + mainColor: '#5469FF', + backgroundColor: '#CCD2FF', + mainColorDisabled: '#9BA2B0', + backgroundColorDisabled: '#D5D7E5', + leftIcon: 'lightbulb', + leftIconSize: 24, + leftIconSizeUnit: 'px', + leftIconColor: '#5469FF', + rightIcon: 'mdi:lightbulb-on', + rightIconSize: 24, + rightIconSizeUnit: 'px', + rightIconColor: '#5469FF', + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + } +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html index 39d073a8c4..f9de708700 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html @@ -1,6 +1,6 @@ + +
+
widgets.action-button.behavior
+
+
widgets.button-state.activated-state
+ +
+
+
widgets.button-state.disabled-state
+ +
+
+
+
widget-config.appearance
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.ts new file mode 100644 index 0000000000..26f785fac9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.ts @@ -0,0 +1,70 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ValueType } from '@shared/models/constants'; +import { getTargetDeviceFromDatasources } from '@shared/models/widget-settings.models'; +import { actionButtonDefaultSettings } from '@home/components/widget/lib/button/action-button-widget.models'; + +@Component({ + selector: 'tb-action-button-widget-settings', + templateUrl: './action-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class ActionButtonWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + const datasources = this.widgetConfig?.config?.datasources; + return getTargetDeviceFromDatasources(datasources); + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + get borderRadius(): string { + return this.widgetConfig?.config?.borderRadius; + } + + valueType = ValueType; + + actionButtonWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.actionButtonWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...actionButtonDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.actionButtonWidgetSettingsForm = this.fb.group({ + activatedState: [settings.activatedState, []], + disabledState: [settings.disabledState, []], + + appearance: [settings.appearance, []] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.html new file mode 100644 index 0000000000..29f3833983 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.html @@ -0,0 +1,49 @@ + + +
+
widgets.command-button.behavior
+
+
widgets.command-button.on-click
+ +
+
+
widgets.button-state.disabled-state
+ +
+
+
+
widget-config.appearance
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.ts new file mode 100644 index 0000000000..ff34a28503 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.ts @@ -0,0 +1,68 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ValueType } from '@shared/models/constants'; +import { commandButtonDefaultSettings } from '@home/components/widget/lib/button/command-button-widget.models'; + +@Component({ + selector: 'tb-command-button-widget-settings', + templateUrl: './command-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class CommandButtonWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + return this.widgetConfig?.config?.targetDevice; + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + get borderRadius(): string { + return this.widgetConfig?.config?.borderRadius; + } + + valueType = ValueType; + + commandButtonWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.commandButtonWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...commandButtonDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.commandButtonWidgetSettingsForm = this.fb.group({ + onClickState: [settings.onClickState, []], + disabledState: [settings.disabledState, []], + + appearance: [settings.appearance, []] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html new file mode 100644 index 0000000000..b37030c884 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html @@ -0,0 +1,142 @@ + + +
+
widgets.power-button.behavior
+
+
widgets.rpc-state.initial-state
+ +
+
+
widgets.power-button.power-on
+ +
+
+
widgets.power-button.power-off
+ +
+
+
widgets.rpc-state.disabled-state
+ +
+
+
+
widget-config.card-style
+ + + {{ powerButtonLayoutTranslationMap.get(layout) | translate }} + + +
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
+
widgets.power-button.button
+
+
{{ 'widgets.power-button.power-on-colors' | translate }}
+
+
+
widgets.power-button.main
+ + +
+ +
+
widgets.power-button.background
+ + +
+
+
+
+
{{ 'widgets.power-button.power-off-colors' | translate }}
+
+
+
widgets.power-button.main
+ + +
+ +
+
widgets.power-button.background
+ + +
+
+
+
+
{{ 'widgets.power-button.disabled-colors' | translate }}
+
+
+
widgets.power-button.main
+ + +
+ +
+
widgets.power-button.background
+ + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts new file mode 100644 index 0000000000..c1d9f98460 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts @@ -0,0 +1,88 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ValueType } from '@shared/models/constants'; +import { + powerButtonDefaultSettings, + powerButtonLayoutImages, + powerButtonLayouts, + powerButtonLayoutTranslations +} from '@home/components/widget/lib/rpc/power-button-widget.models'; + +@Component({ + selector: 'tb-power-button-widget-settings', + templateUrl: './power-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class PowerButtonWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + return this.widgetConfig?.config?.targetDevice; + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + + powerButtonLayouts = powerButtonLayouts; + + powerButtonLayoutTranslationMap = powerButtonLayoutTranslations; + powerButtonLayoutImageMap = powerButtonLayoutImages; + + valueType = ValueType; + + powerButtonWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.powerButtonWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...powerButtonDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.powerButtonWidgetSettingsForm = this.fb.group({ + initialState: [settings.initialState, []], + onUpdateState: [settings.onUpdateState, []], + offUpdateState: [settings.offUpdateState, []], + disabledState: [settings.disabledState, []], + + layout: [settings.layout, []], + + mainColorOn: [settings.mainColorOn, []], + backgroundColorOn: [settings.backgroundColorOn, []], + + mainColorOff: [settings.mainColorOff, []], + backgroundColorOff: [settings.backgroundColorOff, []], + + mainColorDisabled: [settings.mainColorDisabled, []], + backgroundColorDisabled: [settings.backgroundColorDisabled, []], + + background: [settings.background, []] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.html new file mode 100644 index 0000000000..3a7e5865c1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.html @@ -0,0 +1,119 @@ + + +
+
widgets.toggle-button.behavior
+
+
widgets.rpc-state.initial-state
+ +
+
+
widgets.toggle-button.check
+ +
+
+
widgets.toggle-button.uncheck
+ +
+
+
widgets.rpc-state.disabled-state
+ +
+
+
+
widget-config.appearance
+
+ + + + + {{ 'widgets.toggle-button.auto-scale' | translate }} + + + + +
+ + {{ 'widgets.toggle-button.horizontal-fill' | translate }} + +
+
+ + {{ 'widgets.toggle-button.vertical-fill' | translate }} + +
+
+
+
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
+
+
widgets.toggle-button.button-appearance
+ + {{ 'widgets.toggle-button.checked' | translate }} + {{ 'widgets.toggle-button.unchecked' | translate }} + +
+ + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.ts new file mode 100644 index 0000000000..04788e3a14 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.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 { Component } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ValueType } from '@shared/models/constants'; +import { toggleButtonDefaultSettings } from '@home/components/widget/lib/button/toggle-button-widget.models'; + +type ButtonAppearanceType = 'checked' | 'unchecked'; + +@Component({ + selector: 'tb-toggle-button-widget-settings', + templateUrl: './toggle-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class ToggleButtonWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + return this.widgetConfig?.config?.targetDevice; + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + + valueType = ValueType; + + buttonAppearanceType: ButtonAppearanceType = 'checked'; + + toggleButtonWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.toggleButtonWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...toggleButtonDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.toggleButtonWidgetSettingsForm = this.fb.group({ + initialState: [settings.initialState, []], + checkState: [settings.checkState, []], + uncheckState: [settings.uncheckState, []], + disabledState: [settings.disabledState, []], + + autoScale: [settings.autoScale, []], + horizontalFill: [settings.horizontalFill, []], + verticalFill: [settings.verticalFill, []], + + checkedAppearance: [settings.checkedAppearance, []], + uncheckedAppearance: [settings.uncheckedAppearance, []], + + background: [settings.background, []] + }); + } + + protected validatorTriggers(): string[] { + return ['autoScale']; + } + + protected updateValidators(emitEvent: boolean) { + const autoScale: boolean = this.toggleButtonWidgetSettingsForm.get('autoScale').value; + + if (autoScale) { + this.toggleButtonWidgetSettingsForm.get('horizontalFill').disable(); + this.toggleButtonWidgetSettingsForm.get('verticalFill').disable(); + } else { + this.toggleButtonWidgetSettingsForm.get('horizontalFill').enable(); + this.toggleButtonWidgetSettingsForm.get('verticalFill').enable(); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html index 30e0a7ed15..f6f1ef6457 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.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.show-date-time-interval' | 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..b69b023d34 --- /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,175 @@ +/// +/// 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: ['./../widget-settings.scss'] +}) +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, []], + tooltipDateInterval: [settings.tooltipDateInterval, []], + + 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(); + this.barChartWidgetSettingsForm.get('tooltipDateInterval').enable(); + } else { + this.barChartWidgetSettingsForm.get('tooltipDateFormat').disable(); + this.barChartWidgetSettingsForm.get('tooltipDateFont').disable(); + this.barChartWidgetSettingsForm.get('tooltipDateColor').disable(); + this.barChartWidgetSettingsForm.get('tooltipDateInterval').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('tooltipDateInterval').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.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.show-date-time-interval' | 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..f0870dbd80 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts @@ -0,0 +1,152 @@ +/// +/// 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, []], + tooltipDateInterval: [settings.tooltipDateInterval, []], + + 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(); + this.rangeChartWidgetSettingsForm.get('tooltipDateInterval').enable(); + } else { + this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateFont').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateColor').disable(); + this.rangeChartWidgetSettingsForm.get('tooltipDateInterval').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('tooltipDateInterval').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/chart/time-series-chart-bar-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.html new file mode 100644 index 0000000000..447784091e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.html @@ -0,0 +1,67 @@ + + +
+ + {{ 'widgets.time-series-chart.series.bar.show-border' | translate }} + +
+
+
widgets.time-series-chart.series.bar.border-width
+ + + +
+
+
widgets.time-series-chart.series.bar.border-radius
+ + + +
+
+ +
+ {{ 'widgets.time-series-chart.series.bar.label' | translate }} +
+
+
+ + + + {{ seriesLabelPositionTranslations.get(position) | translate }} + + + + + + + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.ts new file mode 100644 index 0000000000..b42cec925a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.ts @@ -0,0 +1,148 @@ +/// +/// 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, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + BarSeriesSettings, + seriesLabelPositions, + seriesLabelPositionTranslations +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge } from 'rxjs'; +import { formatValue, isDefinedAndNotNull } from '@core/utils'; +import { DataKeyConfigComponent } from '@home/components/widget/config/data-key-config.component'; + +@Component({ + selector: 'tb-time-series-chart-bar-settings', + templateUrl: './time-series-chart-bar-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartBarSettingsComponent), + multi: true + } + ] +}) +export class TimeSeriesChartBarSettingsComponent implements OnInit, ControlValueAccessor { + + seriesLabelPositions = seriesLabelPositions; + + seriesLabelPositionTranslations = seriesLabelPositionTranslations; + + labelPreviewFn = this._labelPreviewFn.bind(this); + + @Input() + disabled: boolean; + + private modelValue: BarSeriesSettings; + + private propagateChange = null; + + public barSettingsFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + private dataKeyConfigComponent: DataKeyConfigComponent, + private fb: UntypedFormBuilder) { + } + + ngOnInit(): void { + this.barSettingsFormGroup = this.fb.group({ + showBorder: [null, []], + borderWidth: [null, [Validators.min(0)]], + borderRadius: [null, [Validators.min(0)]], + showLabel: [null, []], + labelPosition: [null, []], + labelFont: [null, []], + labelColor: [null, []], + backgroundSettings: [null, []] + }); + this.barSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + merge(this.barSettingsFormGroup.get('showBorder').valueChanges, + this.barSettingsFormGroup.get('showLabel').valueChanges) + .subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.barSettingsFormGroup.disable({emitEvent: false}); + } else { + this.barSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: BarSeriesSettings): void { + this.modelValue = value; + this.barSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateValidators() { + const showBorder: boolean = this.barSettingsFormGroup.get('showBorder').value; + const showLabel: boolean = this.barSettingsFormGroup.get('showLabel').value; + if (showBorder) { + this.barSettingsFormGroup.get('borderWidth').enable({emitEvent: false}); + } else { + this.barSettingsFormGroup.get('borderWidth').disable({emitEvent: false}); + } + if (showLabel) { + this.barSettingsFormGroup.get('labelPosition').enable({emitEvent: false}); + this.barSettingsFormGroup.get('labelFont').enable({emitEvent: false}); + this.barSettingsFormGroup.get('labelColor').enable({emitEvent: false}); + } else { + this.barSettingsFormGroup.get('labelPosition').disable({emitEvent: false}); + this.barSettingsFormGroup.get('labelFont').disable({emitEvent: false}); + this.barSettingsFormGroup.get('labelColor').disable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.barSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } + + private _labelPreviewFn(): string { + const dataKey = this.dataKeyConfigComponent.modelValue; + const widgetConfig = this.dataKeyConfigComponent.widgetConfig; + const units = dataKey.units && dataKey.units.length ? dataKey.units : widgetConfig.config.units; + const decimals = isDefinedAndNotNull(dataKey.decimals) ? dataKey.decimals : + (isDefinedAndNotNull(widgetConfig.config.decimals) ? widgetConfig.config.decimals : 2); + return formatValue(22, decimals, units, false); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-fill-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-fill-settings.component.html new file mode 100644 index 0000000000..934f985483 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-fill-settings.component.html @@ -0,0 +1,53 @@ + + +
+
+
{{ title | translate }}
+ + {{ seriesFillTypeTranslationMap.get(type) | translate }} + +
+ +
+
widgets.time-series-chart.series.opacity
+ + + +
+
+ +
+
widgets.time-series-chart.series.gradient-stops
+
+
widgets.time-series-chart.series.gradient-start
+ + + +
widgets.time-series-chart.series.gradient-end
+ + + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-fill-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-fill-settings.component.ts new file mode 100644 index 0000000000..44cc870f5b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-fill-settings.component.ts @@ -0,0 +1,143 @@ +/// +/// 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, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + SeriesFillSettings, + SeriesFillType, + seriesFillTypes, + seriesFillTypeTranslations +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@Component({ + selector: 'tb-time-series-chart-fill-settings', + templateUrl: './time-series-chart-fill-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartFillSettingsComponent), + multi: true + } + ] +}) +export class TimeSeriesChartFillSettingsComponent implements OnInit, ControlValueAccessor { + + seriesFillTypes = seriesFillTypes; + + seriesFillTypeTranslationMap: Map = new Map([]); + + SeriesFillType = SeriesFillType; + + @Input() + disabled: boolean; + + @Input() + title = 'widgets.time-series-chart.series.fill'; + + @Input() + fillNoneTitle = 'widgets.time-series-chart.series.fill-type-none'; + + private modelValue: SeriesFillSettings; + + private propagateChange = null; + + public fillSettingsFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + } + + ngOnInit(): void { + this.fillSettingsFormGroup = this.fb.group({ + type: [null, []], + opacity: [null, [Validators.min(0), Validators.max(100)]], + gradient: this.fb.group({ + start: [null, [Validators.min(0), Validators.max(100)]], + end: [null, [Validators.min(0), Validators.max(100)]] + }) + }); + this.fillSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + this.fillSettingsFormGroup.get('type').valueChanges.subscribe(() => { + this.updateValidators(); + }); + for (const type of seriesFillTypes) { + let translation: string; + if (type === SeriesFillType.none) { + translation = this.fillNoneTitle; + } else { + translation = seriesFillTypeTranslations.get(type); + } + this.seriesFillTypeTranslationMap.set(type, translation); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.fillSettingsFormGroup.disable({emitEvent: false}); + } else { + this.fillSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: SeriesFillSettings): void { + this.modelValue = value; + this.fillSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateValidators() { + const type: SeriesFillType = this.fillSettingsFormGroup.get('type').value; + if (type === SeriesFillType.none) { + this.fillSettingsFormGroup.get('opacity').disable({emitEvent: false}); + this.fillSettingsFormGroup.get('gradient').disable({emitEvent: false}); + } else if (type === SeriesFillType.opacity) { + this.fillSettingsFormGroup.get('opacity').enable({emitEvent: false}); + this.fillSettingsFormGroup.get('gradient').disable({emitEvent: false}); + } else if (type === SeriesFillType.gradient) { + this.fillSettingsFormGroup.get('opacity').disable({emitEvent: false}); + this.fillSettingsFormGroup.get('gradient').enable({emitEvent: false}); + } + } + + private updateModel() { + const value: SeriesFillSettings = this.fillSettingsFormGroup.getRawValue(); + this.modelValue = value; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html new file mode 100644 index 0000000000..5f0fbb9e37 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html @@ -0,0 +1,62 @@ + + +
+
widgets.time-series-chart.series.legend-settings
+ +
+ {{ 'widgets.time-series-chart.series.show-in-legend' | translate }} +
+
+ +
+ {{ 'widgets.time-series-chart.series.hidden-by-default' | translate }} +
+
+
+
+
+
widgets.time-series-chart.axis.y-axis
+ + + + {{ yAxis }} + + + +
+
+
+
+
widgets.time-series-chart.series.series-type
+ + {{ timeSeriesChartSeriesTypeTranslations.get(type) | translate }} + +
+ + + + +
+
+ +
{{ timeSeriesChartTypeTranslations.get(chartType) | translate }}
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts new file mode 100644 index 0000000000..d1ac428396 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts @@ -0,0 +1,116 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { isDefinedAndNotNull, mergeDeep } from '@core/utils'; +import { + timeSeriesChartKeyDefaultSettings, + TimeSeriesChartKeySettings, + TimeSeriesChartSeriesType, + timeSeriesChartSeriesTypes, + timeSeriesChartSeriesTypeTranslations, TimeSeriesChartType, timeSeriesChartTypeTranslations, TimeSeriesChartYAxisId +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { TimeSeriesChartWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-widget.models'; + +@Component({ + selector: 'tb-time-series-chart-key-settings', + templateUrl: './time-series-chart-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class TimeSeriesChartKeySettingsComponent extends WidgetSettingsComponent { + + TimeSeriesChartType = TimeSeriesChartType; + + timeSeriesChartTypeTranslations = timeSeriesChartTypeTranslations; + + TimeSeriesChartSeriesType = TimeSeriesChartSeriesType; + + timeSeriesChartSeriesTypes = timeSeriesChartSeriesTypes; + + timeSeriesChartSeriesTypeTranslations = timeSeriesChartSeriesTypeTranslations; + + timeSeriesChartKeySettingsForm: UntypedFormGroup; + + chartType = TimeSeriesChartType.default; + + yAxisIds: TimeSeriesChartYAxisId[]; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.timeSeriesChartKeySettingsForm; + } + + protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) { + const params = widgetConfig.typeParameters as any; + if (isDefinedAndNotNull(params.chartType)) { + this.chartType = params.chartType; + } + const widgetSettings = (widgetConfig.config?.settings || {}) as TimeSeriesChartWidgetSettings; + this.yAxisIds = widgetSettings.yAxes ? Object.keys(widgetSettings.yAxes) : ['default']; + } + + protected defaultSettings(): WidgetSettings { + return mergeDeep({} as TimeSeriesChartKeySettings, + timeSeriesChartKeyDefaultSettings); + } + + protected onSettingsSet(settings: WidgetSettings) { + const seriesSettings = settings as TimeSeriesChartKeySettings; + let yAxisId = seriesSettings.yAxisId; + if (!this.yAxisIds.includes(yAxisId)) { + yAxisId = 'default'; + } + this.timeSeriesChartKeySettingsForm = this.fb.group({ + yAxisId: [yAxisId, []], + showInLegend: [seriesSettings.showInLegend, []], + dataHiddenByDefault: [seriesSettings.dataHiddenByDefault, []], + type: [seriesSettings.type, []], + lineSettings: [seriesSettings.lineSettings, []], + barSettings: [seriesSettings.barSettings, []] + }); + } + + protected validatorTriggers(): string[] { + return ['showInLegend', 'type']; + } + + protected updateValidators(_emitEvent: boolean) { + const showInLegend: boolean = this.timeSeriesChartKeySettingsForm.get('showInLegend').value; + const type: TimeSeriesChartSeriesType = this.timeSeriesChartKeySettingsForm.get('type').value; + if (showInLegend) { + this.timeSeriesChartKeySettingsForm.get('dataHiddenByDefault').enable(); + } else { + this.timeSeriesChartKeySettingsForm.get('dataHiddenByDefault').patchValue(false, {emitEvent: false}); + this.timeSeriesChartKeySettingsForm.get('dataHiddenByDefault').disable(); + } + if (type === TimeSeriesChartSeriesType.line) { + this.timeSeriesChartKeySettingsForm.get('lineSettings').enable(); + this.timeSeriesChartKeySettingsForm.get('barSettings').disable(); + } else if (type === TimeSeriesChartSeriesType.bar) { + this.timeSeriesChartKeySettingsForm.get('lineSettings').disable(); + this.timeSeriesChartKeySettingsForm.get('barSettings').enable(); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html new file mode 100644 index 0000000000..78f1129727 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html @@ -0,0 +1,116 @@ + + +
+
widgets.time-series-chart.series.line.line
+
+ + {{ 'widgets.time-series-chart.series.line.show-line' | translate }} + +
+
+ + {{ 'widgets.time-series-chart.series.line.step-line' | translate }} + + + + + {{ lineSeriesStepTypeTranslations.get(stepType) | translate }} + + + +
+
+ + {{ 'widgets.time-series-chart.series.line.smooth-line' | translate }} + +
+
+
widgets.time-series-chart.line-type
+ + + + {{ timeSeriesLineTypeTranslations.get(lineType) | translate }} + + + +
+
+
widgets.time-series-chart.line-width
+ + + +
+
+
+
widgets.time-series-chart.series.point.points
+
+ + {{ 'widgets.time-series-chart.series.point.show-points' | translate }} + +
+ +
+ + +
+ +
+ +
+ {{ 'widgets.time-series-chart.series.point.point-label' | translate }} +
+
+
+ + + + {{ seriesLabelPositionTranslations.get(position) | translate }} + + + + + + + +
+
+
+
widgets.time-series-chart.series.point.point-shape
+ + + + {{ timeSeriesChartShapeTranslations.get(shape) | translate }} + + + +
+
+
widgets.time-series-chart.series.point.point-size
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts new file mode 100644 index 0000000000..84533b26e4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts @@ -0,0 +1,192 @@ +/// +/// 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, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + LineSeriesSettings, + lineSeriesStepTypes, + lineSeriesStepTypeTranslations, + seriesLabelPositions, + seriesLabelPositionTranslations, + timeSeriesChartShapes, + timeSeriesChartShapeTranslations, TimeSeriesChartType, + timeSeriesLineTypes, + timeSeriesLineTypeTranslations +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge } from 'rxjs'; +import { formatValue, isDefinedAndNotNull } from '@core/utils'; +import { DataKeyConfigComponent } from '@home/components/widget/config/data-key-config.component'; + +@Component({ + selector: 'tb-time-series-chart-line-settings', + templateUrl: './time-series-chart-line-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartLineSettingsComponent), + multi: true + } + ] +}) +export class TimeSeriesChartLineSettingsComponent implements OnInit, ControlValueAccessor { + + TimeSeriesChartType = TimeSeriesChartType; + + lineSeriesStepTypes = lineSeriesStepTypes; + + lineSeriesStepTypeTranslations = lineSeriesStepTypeTranslations; + + timeSeriesLineTypes = timeSeriesLineTypes; + + timeSeriesLineTypeTranslations = timeSeriesLineTypeTranslations; + + seriesLabelPositions = seriesLabelPositions; + + seriesLabelPositionTranslations = seriesLabelPositionTranslations; + + timeSeriesChartShapes = timeSeriesChartShapes; + + timeSeriesChartShapeTranslations = timeSeriesChartShapeTranslations; + + pointLabelPreviewFn = this._pointLabelPreviewFn.bind(this); + + @Input() + disabled: boolean; + + @Input() + chartType: TimeSeriesChartType; + + private modelValue: LineSeriesSettings; + + private propagateChange = null; + + public lineSettingsFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + private dataKeyConfigComponent: DataKeyConfigComponent, + private fb: UntypedFormBuilder) { + } + + ngOnInit(): void { + this.lineSettingsFormGroup = this.fb.group({ + showLine: [null, []], + step: [null, []], + stepType: [null, []], + smooth: [null, []], + lineType: [null, []], + lineWidth: [null, [Validators.min(0)]], + showPoints: [null, []], + showPointLabel: [null, []], + pointLabelPosition: [null, []], + pointLabelFont: [null, []], + pointLabelColor: [null, []], + pointShape: [null, []], + pointSize: [null, [Validators.min(0)]], + fillAreaSettings: [null, []] + }); + this.lineSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + merge(this.lineSettingsFormGroup.get('showLine').valueChanges, + this.lineSettingsFormGroup.get('step').valueChanges, + this.lineSettingsFormGroup.get('showPointLabel').valueChanges) + .subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.lineSettingsFormGroup.disable({emitEvent: false}); + } else { + this.lineSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: LineSeriesSettings): void { + this.modelValue = value; + this.lineSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateValidators() { + const showLine: boolean = this.lineSettingsFormGroup.get('showLine').value; + const step: boolean = this.lineSettingsFormGroup.get('step').value; + const showPointLabel: boolean = this.lineSettingsFormGroup.get('showPointLabel').value; + if (showLine) { + this.lineSettingsFormGroup.get('step').enable({emitEvent: false}); + if (step) { + this.lineSettingsFormGroup.get('stepType').enable({emitEvent: false}); + this.lineSettingsFormGroup.get('smooth').disable({emitEvent: false}); + } else { + this.lineSettingsFormGroup.get('stepType').disable({emitEvent: false}); + this.lineSettingsFormGroup.get('smooth').enable({emitEvent: false}); + } + this.lineSettingsFormGroup.get('lineType').enable({emitEvent: false}); + this.lineSettingsFormGroup.get('lineWidth').enable({emitEvent: false}); + } else { + this.lineSettingsFormGroup.get('step').disable({emitEvent: false}); + this.lineSettingsFormGroup.get('stepType').disable({emitEvent: false}); + this.lineSettingsFormGroup.get('smooth').disable({emitEvent: false}); + this.lineSettingsFormGroup.get('lineType').disable({emitEvent: false}); + this.lineSettingsFormGroup.get('lineWidth').disable({emitEvent: false}); + } + if (showPointLabel) { + this.lineSettingsFormGroup.get('pointLabelPosition').enable({emitEvent: false}); + this.lineSettingsFormGroup.get('pointLabelFont').enable({emitEvent: false}); + this.lineSettingsFormGroup.get('pointLabelColor').enable({emitEvent: false}); + } else { + this.lineSettingsFormGroup.get('pointLabelPosition').disable({emitEvent: false}); + this.lineSettingsFormGroup.get('pointLabelFont').disable({emitEvent: false}); + this.lineSettingsFormGroup.get('pointLabelColor').disable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.lineSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } + + private _pointLabelPreviewFn(): string { + const dataKey = this.dataKeyConfigComponent.modelValue; + const widgetConfig = this.dataKeyConfigComponent.widgetConfig; + const units = dataKey.units && dataKey.units.length ? dataKey.units : widgetConfig.config.units; + const decimals = isDefinedAndNotNull(dataKey.decimals) ? dataKey.decimals : + (isDefinedAndNotNull(widgetConfig.config.decimals) ? widgetConfig.config.decimals : 2); + return formatValue(22, decimals, units, false); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html new file mode 100644 index 0000000000..fbd960be18 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html @@ -0,0 +1,179 @@ + + + + + + +
+
widgets.time-series-chart.chart-style
+
+ + {{ 'widgets.time-series-chart.data-zoom' | translate }} + +
+
+ +
+ {{ 'widgets.time-series-chart.stack-mode' | translate }} +
+
+
+
+
widgets.time-series-chart.axis.x-axis
+ + +
+ + +
+ + + + + {{ 'widget-config.legend' | translate }} + + + + +
+
{{ 'legend.label' | translate }}
+
+ + + + +
+
+ + +
+
+
+
+ + + + + {{ 'widget-config.tooltip' | translate }} + + + + +
+
{{ 'tooltip.trigger' | translate }}
+ + {{ 'tooltip.trigger-point' | translate }} + {{ 'tooltip.trigger-axis' | translate }} + +
+
+
{{ 'tooltip.value' | translate }}
+
+ + + + +
+
+
+ + {{ 'tooltip.date' | translate }} + +
+ + + + + +
+
+
+ +
+ {{ 'tooltip.show-date-time-interval' | translate }} +
+
+
+
+
{{ 'tooltip.background-color' | translate }}
+ + +
+
+
{{ 'tooltip.background-blur' | translate }}
+ + +
px
+
+
+
+
+
+ + +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts new file mode 100644 index 0000000000..6edab7ecf2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts @@ -0,0 +1,222 @@ +/// +/// 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 { + DataKey, + Datasource, + 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, mergeDeep } from '@core/utils'; +import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models'; +import { EChartsTooltipTrigger } from '../../chart/echarts-widget.models'; +import { + timeSeriesChartWidgetDefaultSettings, + TimeSeriesChartWidgetSettings +} from '@home/components/widget/lib/chart/time-series-chart-widget.models'; +import { + TimeSeriesChartKeySettings, + TimeSeriesChartType, TimeSeriesChartYAxes, + TimeSeriesChartYAxisId +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; + +@Component({ + selector: 'tb-time-series-chart-widget-settings', + templateUrl: './time-series-chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsComponent { + + public get datasource(): Datasource { + const datasources: Datasource[] = this.widgetConfig.config.datasources; + if (datasources && datasources.length) { + return datasources[0]; + } else { + return null; + } + } + + public get yAxisIds(): TimeSeriesChartYAxisId[] { + const yAxes: TimeSeriesChartYAxes = this.timeSeriesChartWidgetSettingsForm.get('yAxes').value; + return Object.keys(yAxes); + } + + TimeSeriesChartType = TimeSeriesChartType; + + EChartsTooltipTrigger = EChartsTooltipTrigger; + + legendPositions = legendPositions; + + legendPositionTranslationMap = legendPositionTranslationMap; + + timeSeriesChartWidgetSettingsForm: UntypedFormGroup; + + tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this); + + tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this); + + chartType: TimeSeriesChartType = TimeSeriesChartType.default; + + constructor(protected store: Store, + private $injector: Injector, + private fb: UntypedFormBuilder) { + super(store); + } + + public yAxisRemoved(yAxisId: TimeSeriesChartYAxisId): void { + if (this.widgetConfig.config.datasources && this.widgetConfig.config.datasources.length > 1) { + for (let i = 1; i < this.widgetConfig.config.datasources.length; i++) { + const datasource = this.widgetConfig.config.datasources[i]; + this.removeYaxisId(datasource.dataKeys, yAxisId); + } + } + } + + protected settingsForm(): UntypedFormGroup { + return this.timeSeriesChartWidgetSettingsForm; + } + + protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) { + const params = widgetConfig.typeParameters as any; + if (isDefinedAndNotNull(params.chartType)) { + this.chartType = params.chartType; + } + } + + protected defaultSettings(): WidgetSettings { + return mergeDeep({} as TimeSeriesChartWidgetSettings, timeSeriesChartWidgetDefaultSettings); + } + + protected onSettingsSet(settings: WidgetSettings) { + this.timeSeriesChartWidgetSettingsForm = this.fb.group({ + + yAxes: [settings.yAxes, []], + thresholds: [settings.thresholds, []], + + dataZoom: [settings.dataZoom, []], + stack: [settings.stack, []], + + xAxis: [settings.xAxis, []], + + noAggregationBarWidthSettings: [settings.noAggregationBarWidthSettings, []], + + showLegend: [settings.showLegend, []], + legendLabelFont: [settings.legendLabelFont, []], + legendLabelColor: [settings.legendLabelColor, []], + legendConfig: [settings.legendConfig, []], + + showTooltip: [settings.showTooltip, []], + tooltipTrigger: [settings.tooltipTrigger, []], + tooltipValueFont: [settings.tooltipValueFont, []], + tooltipValueColor: [settings.tooltipValueColor, []], + tooltipShowDate: [settings.tooltipShowDate, []], + tooltipDateFormat: [settings.tooltipDateFormat, []], + tooltipDateFont: [settings.tooltipDateFont, []], + tooltipDateColor: [settings.tooltipDateColor, []], + tooltipDateInterval: [settings.tooltipDateInterval, []], + + tooltipBackgroundColor: [settings.tooltipBackgroundColor, []], + tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []], + + animation: [settings.animation, []], + + background: [settings.background, []], + padding: [settings.padding, []] + }); + } + + protected validatorTriggers(): string[] { + return ['showLegend', 'showTooltip', 'tooltipShowDate']; + } + + protected updateValidators(emitEvent: boolean) { + const showLegend: boolean = this.timeSeriesChartWidgetSettingsForm.get('showLegend').value; + const showTooltip: boolean = this.timeSeriesChartWidgetSettingsForm.get('showTooltip').value; + const tooltipShowDate: boolean = this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').value; + + if (showLegend) { + this.timeSeriesChartWidgetSettingsForm.get('legendLabelFont').enable(); + this.timeSeriesChartWidgetSettingsForm.get('legendLabelColor').enable(); + this.timeSeriesChartWidgetSettingsForm.get('legendConfig').enable(); + } else { + this.timeSeriesChartWidgetSettingsForm.get('legendLabelFont').disable(); + this.timeSeriesChartWidgetSettingsForm.get('legendLabelColor').disable(); + this.timeSeriesChartWidgetSettingsForm.get('legendConfig').disable(); + } + + if (showTooltip) { + this.timeSeriesChartWidgetSettingsForm.get('tooltipTrigger').enable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFont').enable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipValueColor').enable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').enable({emitEvent: false}); + this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundColor').enable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundBlur').enable(); + if (tooltipShowDate) { + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFormat').enable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFont').enable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateColor').enable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateInterval').enable(); + } else { + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFormat').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFont').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateColor').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateInterval').disable(); + } + } else { + this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFont').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipValueColor').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').disable({emitEvent: false}); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFormat').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateFont').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateColor').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipDateInterval').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundColor').disable(); + this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundBlur').disable(); + } + } + + private removeYaxisId(series: DataKey[], yAxisId: TimeSeriesChartYAxisId): boolean { + let changed = false; + if (series) { + series.forEach(key => { + const keySettings = ((key.settings || {}) as TimeSeriesChartKeySettings); + if (keySettings.yAxisId === yAxisId) { + keySettings.yAxisId = 'default'; + changed = true; + } + }); + } + return changed; + } + + private _tooltipValuePreviewFn(): string { + return formatValue(22, 0, '°C', false); + } + + private _tooltipDatePreviewFn(): string { + const dateFormat: DateFormatSettings = this.timeSeriesChartWidgetSettingsForm.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/action/action-settings-button.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.component.html new file mode 100644 index 0000000000..3290099ff9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.component.html @@ -0,0 +1,26 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.scss new file mode 100644 index 0000000000..683b2320f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-button.scss @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.mdc-button.tb-action-settings-button { + width: 100%; + .mdc-button__label { + width: 100%; + & > div { + width: 100%; + display: flex; + flex-direction: row; + gap: 8px; + justify-content: space-between; + align-items: center; + & > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-panel.component.scss new file mode 100644 index 0000000000..d03c178b93 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/action-settings-panel.component.scss @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-action-settings-panel { + width: 530px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-action-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + } + .tb-action-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-action-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html similarity index 92% rename from ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html index 727b5655df..bc8d53ae12 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html @@ -1,6 +1,6 @@ -
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss similarity index 93% rename from ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss index 4eebb52161..e4b6cadea7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.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. @@ -64,7 +64,7 @@ .gutter.gutter-horizontal { cursor: col-resize; - background-image: url("../../../../../../assets/split.js/grips/vertical.png"); + background-image: url("../../../../../../../../../assets/split.js/grips/vertical.png"); } .tb-split.tb-split-horizontal, diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.ts similarity index 94% rename from ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.ts index 5c2111c76e..27b04680b1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.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,7 +15,7 @@ /// // eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// +/// import { AfterViewInit, @@ -35,7 +35,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { combineLatest } from 'rxjs'; import { CustomActionDescriptor } from '@shared/models/widget.models'; -import { CustomPrettyActionEditorCompleter } from '@home/components/widget/action/custom-action.models'; +import { CustomPrettyActionEditorCompleter } from '@home/components/widget/lib/settings/common/action/custom-action.models'; @Component({ selector: 'tb-custom-action-pretty-editor', diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html index f44ff6138c..f9610ecbee 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html @@ -1,6 +1,6 @@ +
+
{{ panelTitle | translate }}
+
+
+
{{ 'widgets.value-action.action' | translate }}
+ + + + {{ getValueActionTranslationsMap.get(action) | translate }} + + + +
+ + +
+
widgets.value-action.value
+ +
+
+ +
+
{{ 'widgets.value-action.method' | translate }}*
+ + + + warning + + +
+
+ + +
+
{{ 'widgets.value-action.attribute-scope' | translate }}
+ + + + {{ 'attribute.scope-any' | translate }} + + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + + +
+
+
{{ 'widgets.value-action.attribute-key' | translate }}*
+ + +
+
+
+ + +
+
{{ 'widgets.value-action.time-series-key' | translate }}*
+ + +
+
+
+
+
+
+
widgets.value-action.action-result-converter
+ + {{ 'widgets.value-action.converter-none' | translate }} + {{ 'widgets.value-action.converter-function' | translate }} + +
+ + +
+
{{ 'widgets.value-action.state-when-result-is' | translate:{state: (stateLabel | translate)} }}
+ + +
+
+
+ + + + + + widget-config.advanced-settings + + + +
+
{{ 'widgets.value-action.request-timeout-ms' | translate }}
+ + + + warning + + +
+
+ + + + + {{ 'widgets.value-action.request-persistent' | translate }} + + + + +
+
{{ 'widgets.value-action.persistent-polling-interval' | translate }}
+ + + + warning + + +
+
+
+
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.ts new file mode 100644 index 0000000000..f9202b982f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.ts @@ -0,0 +1,193 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge } from 'rxjs'; +import { + DataToValueType, + GetValueAction, + getValueActionsByWidgetType, + getValueActionTranslations, + GetValueSettings +} from '@shared/models/action-widget-settings.models'; +import { ValueType } from '@shared/models/constants'; +import { TargetDevice, widgetType } from '@shared/models/widget.models'; +import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-get-value-action-settings-panel', + templateUrl: './get-value-action-settings-panel.component.html', + providers: [], + styleUrls: ['./action-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class GetValueActionSettingsPanelComponent extends PageComponent implements OnInit { + + @Input() + getValueSettings: GetValueSettings; + + @Input() + panelTitle: string; + + @Input() + valueType: ValueType; + + @Input() + trueLabel = 'value.true'; + + @Input() + falseLabel = 'value.false'; + + @Input() + stateLabel: string; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + widgetType: widgetType; + + @Input() + popover: TbPopoverComponent; + + @Output() + getValueSettingsApplied = new EventEmitter>(); + + getValueAction = GetValueAction; + + getValueActions: GetValueAction[]; + + getValueActionTranslationsMap = getValueActionTranslations; + + telemetryTypeTranslationsMap = telemetryTypeTranslationsShort; + + attributeScopes = Object.keys(AttributeScope) as AttributeScope[]; + + dataKeyType = DataKeyType; + + dataToValueType = DataToValueType; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + ValueType = ValueType; + + getValueSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.getValueActions = getValueActionsByWidgetType(this.widgetType); + this.getValueSettingsFormGroup = this.fb.group( + { + action: [this.getValueSettings?.action, []], + defaultValue: [this.getValueSettings?.defaultValue, [Validators.required]], + executeRpc: this.fb.group({ + method: [this.getValueSettings?.executeRpc?.method, [Validators.required]], + requestTimeout: [this.getValueSettings?.executeRpc?.requestTimeout, [Validators.required, Validators.min(5000)]], + requestPersistent: [this.getValueSettings?.executeRpc?.requestPersistent, []], + persistentPollingInterval: + [this.getValueSettings?.executeRpc?.persistentPollingInterval, [Validators.required, Validators.min(1000)]] + }), + getAttribute: this.fb.group({ + scope: [this.getValueSettings?.getAttribute?.scope, []], + key: [this.getValueSettings?.getAttribute?.key, [Validators.required]] + }), + getTimeSeries: this.fb.group({ + key: [this.getValueSettings?.getTimeSeries?.key, [Validators.required]] + }), + dataToValue: this.fb.group({ + type: [this.getValueSettings?.dataToValue?.type, [Validators.required]], + dataToValueFunction: [this.getValueSettings?.dataToValue?.dataToValueFunction, [Validators.required]], + }), + } + ); + if (this.valueType === ValueType.BOOLEAN) { + (this.getValueSettingsFormGroup.get('dataToValue') as UntypedFormGroup).addControl( + 'compareToValue', this.fb.control(this.getValueSettings?.dataToValue?.compareToValue, [Validators.required]) + ); + } + + merge(this.getValueSettingsFormGroup.get('action').valueChanges, + this.getValueSettingsFormGroup.get('dataToValue').get('type').valueChanges, + this.getValueSettingsFormGroup.get('executeRpc').get('requestPersistent').valueChanges).subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyGetValueSettings() { + const getValueSettings: GetValueSettings = this.getValueSettingsFormGroup.getRawValue(); + this.getValueSettingsApplied.emit(getValueSettings); + } + + private updateValidators() { + const action: GetValueAction = this.getValueSettingsFormGroup.get('action').value; + const dataToValueType: DataToValueType = this.getValueSettingsFormGroup.get('dataToValue').get('type').value; + + this.getValueSettingsFormGroup.get('defaultValue').disable({emitEvent: false}); + this.getValueSettingsFormGroup.get('executeRpc').disable({emitEvent: false}); + this.getValueSettingsFormGroup.get('getAttribute').disable({emitEvent: false}); + this.getValueSettingsFormGroup.get('getTimeSeries').disable({emitEvent: false}); + switch (action) { + case GetValueAction.DO_NOTHING: + this.getValueSettingsFormGroup.get('defaultValue').enable({emitEvent: false}); + break; + case GetValueAction.EXECUTE_RPC: + this.getValueSettingsFormGroup.get('executeRpc').enable({emitEvent: false}); + const requestPersistent: boolean = this.getValueSettingsFormGroup.get('executeRpc').get('requestPersistent').value; + if (requestPersistent) { + this.getValueSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').enable({emitEvent: false}); + } else { + this.getValueSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').disable({emitEvent: false}); + } + break; + case GetValueAction.GET_ATTRIBUTE: + this.getValueSettingsFormGroup.get('getAttribute').enable({emitEvent: false}); + break; + case GetValueAction.GET_TIME_SERIES: + this.getValueSettingsFormGroup.get('getTimeSeries').enable({emitEvent: false}); + break; + } + if (action === GetValueAction.DO_NOTHING) { + this.getValueSettingsFormGroup.get('dataToValue').disable({emitEvent: false}); + } else { + this.getValueSettingsFormGroup.get('dataToValue').enable({emitEvent: false}); + if (dataToValueType === DataToValueType.FUNCTION) { + this.getValueSettingsFormGroup.get('dataToValue').get('dataToValueFunction').enable({emitEvent: false}); + } else { + this.getValueSettingsFormGroup.get('dataToValue').get('dataToValueFunction').disable({emitEvent: false}); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings.component.ts new file mode 100644 index 0000000000..4af08a1cee --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings.component.ts @@ -0,0 +1,189 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + forwardRef, + HostBinding, + Input, + OnInit, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { DataToValueType, GetValueAction, GetValueSettings } from '@shared/models/action-widget-settings.models'; +import { TranslateService } from '@ngx-translate/core'; +import { ValueType } from '@shared/models/constants'; +import { + GetValueActionSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component'; +import { IAliasController } from '@core/api/widget-api.models'; +import { TargetDevice, widgetType } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-get-value-action-settings', + templateUrl: './action-settings-button.component.html', + styleUrls: ['./action-settings-button.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GetValueActionSettingsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class GetValueActionSettingsComponent implements OnInit, ControlValueAccessor { + + @HostBinding('style.overflow') + overflow = 'hidden'; + + @Input() + panelTitle: string; + + @Input() + valueType: ValueType; + + @Input() + trueLabel = 'value.true'; + + @Input() + falseLabel = 'value.false'; + + @Input() + stateLabel: string; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + widgetType: widgetType; + + @Input() + disabled = false; + + modelValue: GetValueSettings; + + displayValue: string; + + private propagateChange = null; + + constructor(private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) {} + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + if (this.disabled !== isDisabled) { + this.disabled = isDisabled; + } + } + + writeValue(value: GetValueSettings): void { + this.modelValue = value; + this.updateDisplayValue(); + } + + openActionSettingsPopup($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 = { + getValueSettings: this.modelValue, + panelTitle: this.panelTitle, + valueType: this.valueType, + trueLabel: this.trueLabel, + falseLabel: this.falseLabel, + stateLabel: this.stateLabel, + aliasController: this.aliasController, + targetDevice: this.targetDevice, + widgetType: this.widgetType + }; + const getValueSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, GetValueActionSettingsPanelComponent, + ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + getValueSettingsPanelPopover.tbComponentRef.instance.popover = getValueSettingsPanelPopover; + getValueSettingsPanelPopover.tbComponentRef.instance.getValueSettingsApplied.subscribe((getValueSettings) => { + getValueSettingsPanelPopover.hide(); + this.modelValue = getValueSettings; + this.updateDisplayValue(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateDisplayValue() { + switch (this.modelValue.action) { + case GetValueAction.DO_NOTHING: + if (this.valueType === ValueType.BOOLEAN) { + this.displayValue = + this.translate.instant(!!this.modelValue.defaultValue ? this.trueLabel : this.falseLabel); + } else { + this.displayValue = this.modelValue.defaultValue + ''; + } + break; + case GetValueAction.EXECUTE_RPC: + const methodName = this.modelValue.executeRpc.method; + this.displayValue = this.translate.instant('widgets.value-action.execute-rpc-text', {methodName}); + break; + case GetValueAction.GET_ATTRIBUTE: + this.displayValue = this.translate.instant('widgets.value-action.get-attribute-text', {key: this.modelValue.getAttribute.key}); + break; + case GetValueAction.GET_TIME_SERIES: + this.displayValue = this.translate.instant('widgets.value-action.get-time-series-text', {key: this.modelValue.getTimeSeries.key}); + break; + case GetValueAction.GET_DASHBOARD_STATE: + if (this.valueType === ValueType.BOOLEAN) { + const state = this.modelValue.dataToValue?.compareToValue; + if (this.modelValue.dataToValue?.type === DataToValueType.FUNCTION) { + this.displayValue = this.translate.instant('widgets.value-action.when-dashboard-state-function-is-text', {state}); + } else { + this.displayValue = this.translate.instant('widgets.value-action.when-dashboard-state-is-text', {state}); + } + } else { + this.displayValue = this.translate.instant('widgets.value-action.get-dashboard-state-text'); + } + break; + } + this.cd.markForCheck(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.html similarity index 73% rename from ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.html index 181fe6bc8e..458e619185 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.html @@ -1,6 +1,6 @@ -
- - widget-action.mobile.action-type - - - {{ mobileActionTypeTranslations.get(mobileActionType[actionType]) | translate }} - - - - {{ 'widget-action.mobile.action-type-required' | translate }} - - -
+
+
+
{{ 'widget-action.mobile.action-type' | translate }}*
+ + + + {{ mobileActionTypeTranslations.get(mobileActionType[actionType]) | translate }} + + + + warning + + +
+ @@ -37,6 +45,7 @@ [functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']" [globalVariables]="functionScopeVariables" [editorCompleter]="customActionEditorCompleter" + hideBrackets helpId="widget/action/mobile_get_location_fn" > @@ -47,6 +56,7 @@ [functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']" [globalVariables]="functionScopeVariables" [editorCompleter]="customActionEditorCompleter" + hideBrackets helpId="widget/action/mobile_get_phone_number_fn" > @@ -60,6 +70,7 @@ [functionArgs]="['imageUrl', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']" [globalVariables]="functionScopeVariables" [editorCompleter]="customActionEditorCompleter" + hideBrackets helpId="widget/action/mobile_process_image_fn" > @@ -70,6 +81,7 @@ [functionArgs]="['code', 'format', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']" [globalVariables]="functionScopeVariables" [editorCompleter]="customActionEditorCompleter" + hideBrackets helpId="widget/action/mobile_process_qr_code_fn" > @@ -80,6 +92,7 @@ [functionArgs]="['latitude', 'longitude', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']" [globalVariables]="functionScopeVariables" [editorCompleter]="customActionEditorCompleter" + hideBrackets helpId="widget/action/mobile_process_location_fn" > @@ -93,24 +106,27 @@ [functionArgs]="['launched', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel']" [globalVariables]="functionScopeVariables" [editorCompleter]="customActionEditorCompleter" + hideBrackets helpId="widget/action/mobile_process_launch_result_fn" > -
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts similarity index 90% rename from ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts index f06570cab9..694ce67758 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.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,18 +14,22 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit, QueryList, ViewChildren } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; -import { AppState } from '@app/core/core.state'; +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { + WidgetActionType, WidgetMobileActionDescriptor, WidgetMobileActionType, widgetMobileActionTypeTranslationMap } from '@shared/models/widget.models'; -import { CustomActionEditorCompleter } from '@home/components/widget/action/custom-action.models'; -import { JsFuncComponent } from '@shared/components/js-func.component'; +import { CustomActionEditorCompleter } from '@home/components/widget/lib/settings/common/action/custom-action.models'; import { getDefaultGetLocationFunction, getDefaultGetPhoneNumberFunction, @@ -35,7 +39,7 @@ import { getDefaultProcessLaunchResultFunction, getDefaultProcessLocationFunction, getDefaultProcessQrCodeFunction -} from '@home/components/widget/action/mobile-action-editor.models'; +} from '@home/components/widget/lib/settings/common/action/mobile-action-editor.models'; import { WidgetService } from '@core/http/widget.service'; @Component({ @@ -50,8 +54,6 @@ import { WidgetService } from '@core/http/widget.service'; }) export class MobileActionEditorComponent implements ControlValueAccessor, OnInit { - @ViewChildren(JsFuncComponent) jsFuncComponents: QueryList; - mobileActionTypes = Object.keys(WidgetMobileActionType); mobileActionTypeTranslations = widgetMobileActionTypeTranslationMap; mobileActionType = WidgetMobileActionType; @@ -75,10 +77,9 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit @Input() disabled: boolean; - private propagateChange = (v: any) => { }; + private propagateChange = (_v: any) => { }; - constructor(private store: Store, - private fb: UntypedFormBuilder, + constructor(private fb: UntypedFormBuilder, private widgetService: WidgetService) { this.functionScopeVariables = this.widgetService.getWidgetScopeVariables(); } @@ -87,7 +88,7 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } ngOnInit() { @@ -158,7 +159,7 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit } this.mobileActionTypeFormGroup = this.fb.group({}); if (type) { - let processLaunchResultFunction; + let processLaunchResultFunction: string; switch (type) { case WidgetMobileActionType.takePictureFromGallery: case WidgetMobileActionType.takePhoto: @@ -253,9 +254,5 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit }); } - public validateOnSubmit() { - for (const jsFuncComponent of this.jsFuncComponents.toArray()) { - jsFuncComponent.validateOnSubmit(); - } - } + protected readonly WidgetActionType = WidgetActionType; } diff --git a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.models.ts similarity index 92% rename from ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.models.ts index 89f9bd702c..5abf69c523 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.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. @@ -24,6 +24,7 @@ const processImageFunctionTemplate = '\n' + 'function showImageDialog(title, imageUrl) {\n' + ' setTimeout(function() {\n' + + // eslint-disable-next-line max-len ' widgetContext.customDialog.customDialog(imageDialogTemplate, ImageDialogController, {imageUrl: imageUrl, title: title}).subscribe();\n' + ' }, 100);\n' + '}\n' + @@ -82,6 +83,7 @@ const processImageFunctionTemplate = '}\n'; const processLaunchResultFunctionTemplate = + // eslint-disable-next-line max-len '// Optional function body to process result of attempt to launch external mobile application (for ex. map application or phone call application). \n' + '// - launched - boolean value indicating if the external application was successfully launched.\n\n' + 'showLaunchStatusDialog(\'--TITLE--\', launched);\n' + @@ -166,6 +168,7 @@ const getLocationFunctionTemplate = '\n' + 'function getLocationFromEntityAttributes() {\n' + ' if (entityId) {\n' + + // eslint-disable-next-line max-len ' return widgetContext.attributeService.getEntityAttributes(entityId, \'SERVER_SCOPE\', [\'latitude\', \'longitude\']).pipe(widgetContext.rxjs.map(function(attributeData) {\n' + ' var res = [0,0];\n' + ' if (attributeData && attributeData.length === 2) {\n' + @@ -188,6 +191,7 @@ const getPhoneNumberFunctionTemplate = '\n' + 'function getPhoneNumberFromEntityAttributes() {\n' + ' if (entityId) {\n' + + // eslint-disable-next-line max-len ' return widgetContext.attributeService.getEntityAttributes(entityId, \'SERVER_SCOPE\', [\'phone\']).pipe(widgetContext.rxjs.map(function(attributeData) {\n' + ' var res = 0;\n' + ' if (attributeData && attributeData.length === 1) {\n' + @@ -200,8 +204,8 @@ const getPhoneNumberFunctionTemplate = ' }\n' + '}\n'; -export function getDefaultProcessImageFunction(type: WidgetMobileActionType): string { - let title; +export const getDefaultProcessImageFunction = (type: WidgetMobileActionType): string => { + let title: string; switch (type) { case WidgetMobileActionType.takePictureFromGallery: title = 'Gallery picture'; @@ -214,10 +218,10 @@ export function getDefaultProcessImageFunction(type: WidgetMobileActionType): st break; } return processImageFunctionTemplate.replace('--TITLE--', title); -} +}; -export function getDefaultProcessLaunchResultFunction(type: WidgetMobileActionType): string { - let title; +export const getDefaultProcessLaunchResultFunction = (type: WidgetMobileActionType): string => { + let title: string; switch (type) { case WidgetMobileActionType.mapLocation: title = 'Map location'; @@ -230,25 +234,17 @@ export function getDefaultProcessLaunchResultFunction(type: WidgetMobileActionTy break; } return processLaunchResultFunctionTemplate.replace('--TITLE--', title); -} +}; -export function getDefaultProcessQrCodeFunction() { - return processQrCodeFunction; -} +export const getDefaultProcessQrCodeFunction = () => processQrCodeFunction; -export function getDefaultProcessLocationFunction() { - return processLocationFunction; -} +export const getDefaultProcessLocationFunction = () => processLocationFunction; -export function getDefaultGetLocationFunction() { - return getLocationFunctionTemplate; -} +export const getDefaultGetLocationFunction = () => getLocationFunctionTemplate; -export function getDefaultGetPhoneNumberFunction() { - return getPhoneNumberFunctionTemplate; -} +export const getDefaultGetPhoneNumberFunction = () => getPhoneNumberFunctionTemplate; -export function getDefaultHandleEmptyResultFunction(type: WidgetMobileActionType): string { +export const getDefaultHandleEmptyResultFunction = (type: WidgetMobileActionType): string => { let message = 'Mobile action was cancelled!'; switch (type) { case WidgetMobileActionType.takePictureFromGallery: @@ -277,9 +273,9 @@ export function getDefaultHandleEmptyResultFunction(type: WidgetMobileActionType break; } return handleEmptyResultFunctionTemplate.replace('--MESSAGE--', message); -} +}; -export function getDefaultHandleErrorFunction(type: WidgetMobileActionType): string { +export const getDefaultHandleErrorFunction = (type: WidgetMobileActionType): string => { let title = 'Mobile action failed'; switch (type) { case WidgetMobileActionType.takePictureFromGallery: @@ -308,4 +304,4 @@ export function getDefaultHandleErrorFunction(type: WidgetMobileActionType): str break; } return handleErrorFunctionTemplate.replace('--TITLE--', title); -} +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html new file mode 100644 index 0000000000..4fc8f1fdce --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html @@ -0,0 +1,202 @@ + +
+
{{ panelTitle | translate }}
+
+
+
{{ 'widgets.value-action.action' | translate }}
+ + + + {{ setValueActionTranslationsMap.get(action) | translate }} + + + +
+ + +
+
{{ 'widgets.value-action.method' | translate }}*
+ + + + warning + + +
+
+ + +
+
{{ 'widgets.value-action.attribute-scope' | translate }}
+ + + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + + +
+
+
{{ 'widgets.value-action.attribute-key' | translate }}*
+ + +
+
+
+ + +
+
{{ 'widgets.value-action.time-series-key' | translate }}*
+ + +
+
+
+
+
+
+
{{ (setValueSettingsFormGroup.get('action').value === setValueAction.EXECUTE_RPC ? + 'widgets.value-action.parameters' : 'widgets.value-action.value') | translate }}
+ + {{ 'widgets.value-action.converter-value' | translate }} + {{ 'widgets.value-action.converter-constant' | translate }} + {{ 'widgets.value-action.converter-function' | translate }} + {{ 'widgets.value-action.converter-none' | translate }} + +
+ + + + +
+
+ + + + + + widget-config.advanced-settings + + + +
+
{{ 'widgets.value-action.request-timeout-ms' | translate }}
+ + + + warning + + +
+
+ + + + + {{ 'widgets.value-action.request-persistent' | translate }} + + + + +
+
{{ 'widgets.value-action.persistent-polling-interval' | translate }}
+ + + + warning + + +
+
+
+
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.ts new file mode 100644 index 0000000000..819ed2a3fc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.ts @@ -0,0 +1,185 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge } from 'rxjs'; +import { + SetValueAction, + setValueActionsByWidgetType, + setValueActionTranslations, + SetValueSettings, + ValueToDataType +} from '@shared/models/action-widget-settings.models'; +import { TargetDevice, widgetType } from '@shared/models/widget.models'; +import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetService } from '@core/http/widget.service'; +import { ValueType } from '@shared/models/constants'; + +@Component({ + selector: 'tb-set-value-action-settings-panel', + templateUrl: './set-value-action-settings-panel.component.html', + providers: [], + styleUrls: ['./action-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SetValueActionSettingsPanelComponent extends PageComponent implements OnInit { + + @Input() + panelTitle: string; + + @Input() + valueType = ValueType.BOOLEAN; + + @Input() + setValueSettings: SetValueSettings; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + widgetType: widgetType; + + @Input() + popover: TbPopoverComponent; + + @Output() + setValueSettingsApplied = new EventEmitter(); + + setValueAction = SetValueAction; + + setValueActions: SetValueAction[]; + + setValueActionTranslationsMap = setValueActionTranslations; + + telemetryTypeTranslationsMap = telemetryTypeTranslationsShort; + + attributeScopes = [AttributeScope.SERVER_SCOPE, AttributeScope.SHARED_SCOPE]; + + dataKeyType = DataKeyType; + + valueToDataType = ValueToDataType; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + ValueType = ValueType; + + setValueSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.setValueActions = setValueActionsByWidgetType(this.widgetType); + this.setValueSettingsFormGroup = this.fb.group( + { + action: [this.setValueSettings?.action, []], + executeRpc: this.fb.group({ + method: [this.setValueSettings?.executeRpc?.method, [Validators.required]], + requestTimeout: [this.setValueSettings?.executeRpc?.requestTimeout, [Validators.required, Validators.min(5000)]], + requestPersistent: [this.setValueSettings?.executeRpc?.requestPersistent, []], + persistentPollingInterval: + [this.setValueSettings?.executeRpc?.persistentPollingInterval, [Validators.required, Validators.min(1000)]] + }), + setAttribute: this.fb.group({ + scope: [this.setValueSettings?.setAttribute?.scope, []], + key: [this.setValueSettings?.setAttribute?.key, [Validators.required]], + }), + putTimeSeries: this.fb.group({ + key: [this.setValueSettings?.putTimeSeries?.key, [Validators.required]], + }), + valueToData: this.fb.group({ + type: [this.setValueSettings?.valueToData?.type, [Validators.required]], + constantValue: [this.setValueSettings?.valueToData?.constantValue, [Validators.required]], + valueToDataFunction: [this.setValueSettings?.valueToData?.valueToDataFunction, [Validators.required]], + }), + } + ); + + merge(this.setValueSettingsFormGroup.get('action').valueChanges, + this.setValueSettingsFormGroup.get('valueToData').get('type').valueChanges, + this.setValueSettingsFormGroup.get('executeRpc').get('requestPersistent').valueChanges).subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applySetValueSettings() { + const setValueSettings: SetValueSettings = this.setValueSettingsFormGroup.getRawValue(); + this.setValueSettingsApplied.emit(setValueSettings); + } + + private updateValidators() { + const action: SetValueAction = this.setValueSettingsFormGroup.get('action').value; + let valueToDataType: ValueToDataType = this.setValueSettingsFormGroup.get('valueToData').get('type').value; + + this.setValueSettingsFormGroup.get('executeRpc').disable({emitEvent: false}); + this.setValueSettingsFormGroup.get('setAttribute').disable({emitEvent: false}); + this.setValueSettingsFormGroup.get('putTimeSeries').disable({emitEvent: false}); + switch (action) { + case SetValueAction.EXECUTE_RPC: + this.setValueSettingsFormGroup.get('executeRpc').enable({emitEvent: false}); + const requestPersistent: boolean = this.setValueSettingsFormGroup.get('executeRpc').get('requestPersistent').value; + if (requestPersistent) { + this.setValueSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').enable({emitEvent: false}); + } else { + this.setValueSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').disable({emitEvent: false}); + } + break; + case SetValueAction.SET_ATTRIBUTE: + case SetValueAction.ADD_TIME_SERIES: + if (valueToDataType === ValueToDataType.NONE) { + valueToDataType = ValueToDataType.CONSTANT; + this.setValueSettingsFormGroup.get('valueToData').get('type').patchValue(valueToDataType, {emitEvent: false}); + } + if (action === SetValueAction.SET_ATTRIBUTE) { + this.setValueSettingsFormGroup.get('setAttribute').enable({emitEvent: false}); + } else { + this.setValueSettingsFormGroup.get('putTimeSeries').enable({emitEvent: false}); + } + break; + } + switch (valueToDataType) { + case ValueToDataType.CONSTANT: + this.setValueSettingsFormGroup.get('valueToData').get('constantValue').enable({emitEvent: false}); + this.setValueSettingsFormGroup.get('valueToData').get('valueToDataFunction').disable({emitEvent: false}); + break; + case ValueToDataType.FUNCTION: + this.setValueSettingsFormGroup.get('valueToData').get('constantValue').disable({emitEvent: false}); + this.setValueSettingsFormGroup.get('valueToData').get('valueToDataFunction').enable({emitEvent: false}); + break; + case ValueToDataType.NONE: + this.setValueSettingsFormGroup.get('valueToData').get('constantValue').disable({emitEvent: false}); + this.setValueSettingsFormGroup.get('valueToData').get('valueToDataFunction').disable({emitEvent: false}); + break; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings.component.ts new file mode 100644 index 0000000000..7b4c8e0428 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings.component.ts @@ -0,0 +1,177 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + forwardRef, + HostBinding, + Input, + OnInit, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { SetValueAction, SetValueSettings, ValueToDataType } from '@shared/models/action-widget-settings.models'; +import { TranslateService } from '@ngx-translate/core'; +import { IAliasController } from '@core/api/widget-api.models'; +import { TargetDevice, widgetType } from '@shared/models/widget.models'; +import { isDefinedAndNotNull } from '@core/utils'; +import { + SetValueActionSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component'; +import { ValueType } from '@shared/models/constants'; + +@Component({ + selector: 'tb-set-value-action-settings', + templateUrl: './action-settings-button.component.html', + styleUrls: ['./action-settings-button.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SetValueActionSettingsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class SetValueActionSettingsComponent implements OnInit, ControlValueAccessor { + + @HostBinding('style.overflow') + overflow = 'hidden'; + + @Input() + panelTitle: string; + + @Input() + valueType = ValueType.BOOLEAN; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + widgetType: widgetType; + + @Input() + disabled = false; + + modelValue: SetValueSettings; + + displayValue: string; + + private propagateChange = null; + + constructor(private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) {} + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + if (this.disabled !== isDisabled) { + this.disabled = isDisabled; + } + } + + writeValue(value: SetValueSettings): void { + this.modelValue = value; + this.updateDisplayValue(); + } + + openActionSettingsPopup($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 = { + setValueSettings: this.modelValue, + panelTitle: this.panelTitle, + valueType: this.valueType, + aliasController: this.aliasController, + targetDevice: this.targetDevice, + widgetType: this.widgetType + }; + const setValueSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, SetValueActionSettingsPanelComponent, + ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + setValueSettingsPanelPopover.tbComponentRef.instance.popover = setValueSettingsPanelPopover; + setValueSettingsPanelPopover.tbComponentRef.instance.setValueSettingsApplied.subscribe((setValueSettings) => { + setValueSettingsPanelPopover.hide(); + this.modelValue = setValueSettings; + this.updateDisplayValue(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateDisplayValue() { + let value: any; + switch (this.modelValue.valueToData.type) { + case ValueToDataType.VALUE: + value = 'value'; + break; + case ValueToDataType.CONSTANT: + value = this.modelValue.valueToData.constantValue; + break; + case ValueToDataType.FUNCTION: + value = 'f(value)'; + break; + case ValueToDataType.NONE: + break; + } + switch (this.modelValue.action) { + case SetValueAction.EXECUTE_RPC: + let methodName = this.modelValue.executeRpc.method; + if (isDefinedAndNotNull(value)) { + methodName = `${methodName}(${value})`; + } + this.displayValue = this.translate.instant('widgets.value-action.execute-rpc-text', {methodName}); + break; + case SetValueAction.SET_ATTRIBUTE: + this.displayValue = this.translate.instant('widgets.value-action.set-attribute-to-value-text', + {key: this.modelValue.setAttribute.key, value}); + break; + case SetValueAction.ADD_TIME_SERIES: + this.displayValue = this.translate.instant('widgets.value-action.add-time-series-value-text', + {key: this.modelValue.putTimeSeries.key, value}); + break; + } + this.cd.markForCheck(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html new file mode 100644 index 0000000000..c5b4b50272 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html @@ -0,0 +1,42 @@ + +
+
{{ panelTitle | translate }}
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts new file mode 100644 index 0000000000..8ed53fd95e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts @@ -0,0 +1,88 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge } from 'rxjs'; +import { + DataToValueType, + GetValueAction, + getValueActions, + getValueActionTranslations, + GetValueSettings +} from '@shared/models/action-widget-settings.models'; +import { ValueType } from '@shared/models/constants'; +import { TargetDevice, WidgetAction, widgetType } from '@shared/models/widget.models'; +import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetService } from '@core/http/widget.service'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; + +@Component({ + selector: 'tb-widget-action-settings-panel', + templateUrl: './widget-action-settings-panel.component.html', + providers: [], + styleUrls: ['./action-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class WidgetActionSettingsPanelComponent extends PageComponent implements OnInit { + + @Input() + widgetAction: WidgetAction; + + @Input() + panelTitle: string; + + @Input() + widgetType: widgetType; + + @Input() + callbacks: WidgetActionCallbacks; + + @Input() + popover: TbPopoverComponent; + + @Output() + widgetActionApplied = new EventEmitter(); + + widgetActionFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.widgetActionFormGroup = this.fb.group( + { + widgetAction: [this.widgetAction, []] + } + ); + } + + cancel() { + this.popover?.hide(); + } + + applyWidgetAction() { + const widgetAction: WidgetAction = this.widgetActionFormGroup.get('widgetAction').getRawValue(); + this.widgetActionApplied.emit(widgetAction); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts new file mode 100644 index 0000000000..95ec0b1262 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.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 { + ChangeDetectorRef, + Component, + forwardRef, + HostBinding, + Input, + OnInit, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { TranslateService } from '@ngx-translate/core'; +import { WidgetAction, widgetActionTypeTranslationMap, widgetType } from '@shared/models/widget.models'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; +import { + WidgetActionSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/action/widget-action-settings-panel.component'; + +@Component({ + selector: 'tb-widget-action-settings', + templateUrl: './action-settings-button.component.html', + styleUrls: ['./action-settings-button.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetActionSettingsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class WidgetActionSettingsComponent implements OnInit, ControlValueAccessor { + + @HostBinding('style.overflow') + overflow = 'hidden'; + + @Input() + panelTitle: string; + + @Input() + widgetType: widgetType; + + @Input() + callbacks: WidgetActionCallbacks; + + @Input() + disabled = false; + + modelValue: WidgetAction; + + displayValue: string; + + private propagateChange = null; + + constructor(private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) {} + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + if (this.disabled !== isDisabled) { + this.disabled = isDisabled; + } + } + + writeValue(value: WidgetAction): void { + this.modelValue = value; + this.updateDisplayValue(); + } + + openActionSettingsPopup($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 = { + widgetAction: this.modelValue, + panelTitle: this.panelTitle, + widgetType: this.widgetType, + callbacks: this.callbacks + }; + const widgetActionSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, WidgetActionSettingsPanelComponent, + ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + widgetActionSettingsPanelPopover.tbComponentRef.instance.popover = widgetActionSettingsPanelPopover; + widgetActionSettingsPanelPopover.tbComponentRef.instance.widgetActionApplied.subscribe((widgetAction) => { + widgetActionSettingsPanelPopover.hide(); + this.modelValue = widgetAction; + this.updateDisplayValue(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateDisplayValue() { + this.displayValue = this.translate.instant(widgetActionTypeTranslationMap.get(this.modelValue.type)); + this.cd.markForCheck(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html new file mode 100644 index 0000000000..be9b067d17 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html @@ -0,0 +1,253 @@ + +
+
+
widget-config.action
+ + + + {{ widgetActionTypeTranslations.get(widgetActionType[actionType]) | translate }} + + + +
+ + +
+
{{ 'widget-action.target-dashboard' | translate }}*
+ +
+
+ +
+
+ {{ 'widget-action.target-dashboard-state' | translate }} + * +
+ + + + + warning + + + + + + + +
+
+ +
+
{{ 'widget-action.URL' | translate }}
+ + + + warning + + +
+
+ +
+ + {{ 'widget-action.open-right-layout' | translate }} + +
+
+ +
+ + {{ 'widget-action.open-new-browser-tab' | translate }} + +
+
+ +
+ + {{ 'widget-action.set-entity-from-widget' | translate }} + +
+
+
{{ 'alias.state-entity-parameter-name' | translate }}
+ + + +
+
+ +
+
{{ 'widget-action.state-display-type' | translate }}
+ + + + {{ stateDisplayTypeName(displayType) }} + + + +
+ + +
+
{{ 'widget-action.dialog-title' | translate }}
+ + + +
+
+ + {{ 'widget-action.dialog-hide-dashboard-toolbar' | translate }} + +
+
+
{{ 'widget-action.dialog-width' | translate }}
+ + + + warning + + +
+
+
{{ 'widget-action.dialog-height' | translate }}
+ + + + warning + + +
+
+ +
+
{{ 'widget-action.popover-preferred-placement' | translate }}
+ + + + {{ popoverPlacementName(placement) }} + + + +
+
+ + {{ 'widget-action.popover-hide-on-click-outside' | translate }} + +
+
+ + {{ 'widget-action.popover-hide-dashboard-toolbar' | translate }} + +
+
+
{{ 'widget-action.popover-width' | translate }}
+ + +
+
+
{{ 'widget-action.popover-height' | translate }}
+ + +
+ +
+ + +
+
+ + + + + + + + + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts new file mode 100644 index 0000000000..6c515ad0a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts @@ -0,0 +1,480 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { + WidgetAction, + WidgetActionType, + widgetActionTypes, + widgetActionTypeTranslationMap, + widgetType +} from '@shared/models/widget.models'; +import { WidgetService } from '@core/http/widget.service'; +import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; +import { map, mergeMap, share, startWith, takeUntil, tap } from 'rxjs/operators'; +import { Observable, of, ReplaySubject, Subject, Subscription } from 'rxjs'; +import { Dashboard } from '@shared/models/dashboard.models'; +import { DashboardService } from '@core/http/dashboard.service'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; +import { isDefinedAndNotNull } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { PopoverPlacement, PopoverPlacements } from '@shared/components/popover.models'; +import { + CustomActionEditorCompleter, + toCustomAction +} from '@home/components/widget/lib/settings/common/action/custom-action.models'; + +const stateDisplayTypes = ['normal', 'separateDialog', 'popover'] as const; +type stateDisplayTypeTuple = typeof stateDisplayTypes; +export type stateDisplayType = stateDisplayTypeTuple[number]; + +const stateDisplayTypesTranslations = new Map( + [ + ['normal', 'widget-action.open-normal'], + ['separateDialog', 'widget-action.open-in-separate-dialog'], + ['popover', 'widget-action.open-in-popover'], + ] +); + +@Component({ + selector: 'tb-widget-action', + templateUrl: './widget-action.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetActionComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => WidgetActionComponent), + multi: true, + } + ] +}) +export class WidgetActionComponent implements ControlValueAccessor, OnInit, Validator { + + @ViewChild('dashboardStateInput', {static: false}) dashboardStateInput: ElementRef; + + @Input() + disabled: boolean; + + @Input() + widgetType: widgetType; + + @Input() + callbacks: WidgetActionCallbacks; + + widgetActionTypes = widgetActionTypes; + widgetActionTypeTranslations = widgetActionTypeTranslationMap; + widgetActionType = WidgetActionType; + + allStateDisplayTypes = stateDisplayTypes; + allPopoverPlacements = PopoverPlacements; + + WidgetType = widgetType; + + filteredDashboardStates: Observable>; + targetDashboardStateSearchText = ''; + selectedDashboardStateIds: Observable>; + + customActionEditorCompleter = CustomActionEditorCompleter; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + widgetActionFormGroup: UntypedFormGroup; + actionTypeFormGroup: UntypedFormGroup; + stateDisplayTypeFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + private actionTypeFormGroupSubscriptions: Subscription[] = []; + private stateDisplayTypeFormGroupSubscriptions: Subscription[] = []; + private destroy$ = new Subject(); + private dashboard: Dashboard; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private dashboardService: DashboardService, + private dashboardUtils: DashboardUtilsService, + private translate: TranslateService) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.widgetActionFormGroup.disable({emitEvent: false}); + if (this.actionTypeFormGroup) { + this.actionTypeFormGroup.disable({emitEvent: false}); + } + if (this.stateDisplayTypeFormGroup) { + this.stateDisplayTypeFormGroup.disable({emitEvent: false}); + } + } else { + this.widgetActionFormGroup.enable({emitEvent: false}); + } + } + + ngOnInit() { + this.widgetActionFormGroup = this.fb.group({}); + this.widgetActionFormGroup.addControl('type', + this.fb.control(null, [Validators.required])); + this.widgetActionFormGroup.get('type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((type: WidgetActionType) => { + this.updateActionTypeFormGroup(type); + }); + this.widgetActionFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.widgetActionUpdated(); + }); + } + + writeValue(widgetAction?: WidgetAction): void { + this.widgetActionFormGroup.patchValue({ + type: widgetAction?.type + }, {emitEvent: false}); + this.updateActionTypeFormGroup(widgetAction?.type, widgetAction); + } + + validate(_c: UntypedFormControl) { + return (this.widgetActionFormGroup.valid && + this.actionTypeFormGroup.valid && (!this.stateDisplayTypeFormGroup || this.stateDisplayTypeFormGroup.valid)) ? null : { + widgetAction: { + valid: false, + } + }; + } + + clearTargetDashboardState(value: string = '') { + this.dashboardStateInput.nativeElement.value = value; + this.actionTypeFormGroup.get('targetDashboardStateId').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.dashboardStateInput.nativeElement.blur(); + this.dashboardStateInput.nativeElement.focus(); + }, 0); + } + + onDashboardStateInputFocus(): void { + this.actionTypeFormGroup.get('targetDashboardStateId').updateValueAndValidity({onlySelf: true, emitEvent: true}); + } + + stateDisplayTypeName(displayType: stateDisplayType): string { + if (displayType) { + return this.translate.instant(stateDisplayTypesTranslations.get(displayType)) + ''; + } else { + return ''; + } + } + + popoverPlacementName(placement: PopoverPlacement): string { + if (placement) { + return this.translate.instant(`widget-action.popover-placement-${placement}`) + ''; + } else { + return ''; + } + } + + private updateActionTypeFormGroup(type?: WidgetActionType, action?: WidgetAction) { + this.actionTypeFormGroupSubscriptions.forEach(s => s.unsubscribe()); + this.actionTypeFormGroupSubscriptions.length = 0; + this.actionTypeFormGroup = this.fb.group({}); + if (type) { + switch (type) { + case WidgetActionType.openDashboard: + case WidgetActionType.openDashboardState: + case WidgetActionType.updateDashboardState: + this.actionTypeFormGroup.addControl( + 'targetDashboardStateId', + this.fb.control(action ? action.targetDashboardStateId : null, + type === WidgetActionType.openDashboardState ? [Validators.required] : []) + ); + this.actionTypeFormGroup.addControl( + 'setEntityId', + this.fb.control(this.widgetType === widgetType.static ? false : action ? action.setEntityId : true, []) + ); + this.actionTypeFormGroup.addControl( + 'stateEntityParamName', + this.fb.control(action ? action.stateEntityParamName : null, []) + ); + if (type === WidgetActionType.openDashboard) { + const targetDashboardId = action ? action.targetDashboardId : null; + this.actionTypeFormGroup.addControl( + 'openNewBrowserTab', + this.fb.control(action ? action.openNewBrowserTab : false, []) + ); + this.actionTypeFormGroup.addControl( + 'targetDashboardId', + this.fb.control(targetDashboardId, [Validators.required]) + ); + this.setupSelectedDashboardStateIds(targetDashboardId); + } else { + if (type === WidgetActionType.openDashboardState) { + const displayType = this.getStateDisplayType(action); + this.actionTypeFormGroup.addControl( + 'stateDisplayType', + this.fb.control(this.getStateDisplayType(action), [Validators.required]) + ); + this.updateStateDisplayTypeFormGroup(displayType, action); + this.actionTypeFormGroupSubscriptions.push( + this.actionTypeFormGroup.get('stateDisplayType').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((displayTypeValue: stateDisplayType) => { + this.updateStateDisplayTypeFormGroup(displayTypeValue); + }) + ); + } + this.actionTypeFormGroup.addControl( + 'openRightLayout', + this.fb.control(action ? action.openRightLayout : false, []) + ); + } + this.setupFilteredDashboardStates(); + break; + case WidgetActionType.custom: + this.actionTypeFormGroup.addControl( + 'customFunction', + this.fb.control(action ? action.customFunction : null, []) + ); + break; + case WidgetActionType.customPretty: + this.actionTypeFormGroup.addControl( + 'customAction', + this.fb.control(toCustomAction(action), [Validators.required]) + ); + break; + case WidgetActionType.mobileAction: + this.actionTypeFormGroup.addControl( + 'mobileAction', + this.fb.control(action ? action.mobileAction : null, [Validators.required]) + ); + break; + case WidgetActionType.openURL: + this.actionTypeFormGroup.addControl( + 'openNewBrowserTab', + this.fb.control(action ? action.openNewBrowserTab : false, []) + ); + this.actionTypeFormGroup.addControl( + 'url', + this.fb.control(action ? action.url : null, [Validators.required]) + ); + break; + } + } + this.actionTypeFormGroupSubscriptions.push( + this.actionTypeFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.widgetActionUpdated(); + }) + ); + } + + private updateStateDisplayTypeFormGroup(displayType?: stateDisplayType, action?: WidgetAction) { + this.stateDisplayTypeFormGroupSubscriptions.forEach(s => s.unsubscribe()); + this.stateDisplayTypeFormGroupSubscriptions.length = 0; + this.stateDisplayTypeFormGroup = this.fb.group({}); + if (displayType) { + switch (displayType) { + case 'normal': + break; + case 'separateDialog': + this.stateDisplayTypeFormGroup.addControl( + 'dialogTitle', + this.fb.control(action ? action.dialogTitle : '', []) + ); + this.stateDisplayTypeFormGroup.addControl( + 'dialogHideDashboardToolbar', + this.fb.control(action && isDefinedAndNotNull(action.dialogHideDashboardToolbar) + ? action.dialogHideDashboardToolbar : true, []) + ); + this.stateDisplayTypeFormGroup.addControl( + 'dialogWidth', + this.fb.control(action ? action.dialogWidth : null, [Validators.min(1), Validators.max(100)]) + ); + this.stateDisplayTypeFormGroup.addControl( + 'dialogHeight', + this.fb.control(action ? action.dialogHeight : null, [Validators.min(1), Validators.max(100)]) + ); + break; + case 'popover': + this.stateDisplayTypeFormGroup.addControl( + 'popoverPreferredPlacement', + this.fb.control(action && isDefinedAndNotNull(action.popoverPreferredPlacement) + ? action.popoverPreferredPlacement : 'top', []) + ); + this.stateDisplayTypeFormGroup.addControl( + 'popoverHideOnClickOutside', + this.fb.control(action && isDefinedAndNotNull(action.popoverHideOnClickOutside) + ? action.popoverHideOnClickOutside : true, []) + ); + this.stateDisplayTypeFormGroup.addControl( + 'popoverHideDashboardToolbar', + this.fb.control(action && isDefinedAndNotNull(action.popoverHideDashboardToolbar) + ? action.popoverHideDashboardToolbar : true, []) + ); + this.stateDisplayTypeFormGroup.addControl( + 'popoverWidth', + this.fb.control(action && isDefinedAndNotNull(action.popoverWidth) ? action.popoverWidth : '25vw', []) + ); + this.stateDisplayTypeFormGroup.addControl( + 'popoverHeight', + this.fb.control(action && isDefinedAndNotNull(action.popoverHeight) ? action.popoverHeight : '25vh', []) + ); + this.stateDisplayTypeFormGroup.addControl( + 'popoverStyle', + this.fb.control(action && isDefinedAndNotNull(action.popoverStyle) ? action.popoverStyle : {}, []) + ); + break; + } + } + this.stateDisplayTypeFormGroupSubscriptions.push( + this.stateDisplayTypeFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.widgetActionUpdated(); + }) + ); + } + + private setupSelectedDashboardStateIds(targetDashboardId: string | null) { + this.selectedDashboardStateIds = + this.actionTypeFormGroup.get('targetDashboardId').valueChanges.pipe( + startWith(targetDashboardId), + tap((dashboardId) => { + if (!dashboardId) { + this.actionTypeFormGroup.get('targetDashboardStateId') + .patchValue('', {emitEvent: true}); + } + + this.targetDashboardStateSearchText = ''; + }), + mergeMap((dashboardId) => { + if (dashboardId) { + if (this.dashboard?.id.id === dashboardId) { + return of(this.dashboard); + } else { + return this.dashboardService.getDashboard(dashboardId); + } + } else { + return of(null); + } + }), + map((dashboard: Dashboard) => { + if (dashboard) { + if (this.dashboard?.id.id !== dashboard.id.id) { + this.dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard); + } + + return Object.keys(this.dashboard.configuration.states); + } else { + return []; + } + }), + share({ + connector: () => new ReplaySubject(1), + resetOnError: false, + resetOnComplete: false, + resetOnRefCountZero: false + }) + ); + } + + private setupFilteredDashboardStates() { + this.targetDashboardStateSearchText = ''; + this.filteredDashboardStates = this.actionTypeFormGroup.get('targetDashboardStateId').valueChanges + .pipe( + startWith(''), + map(value => value ? value : ''), + mergeMap(name => this.fetchDashboardStates(name)), + takeUntil(this.destroy$) + ); + } + + private fetchDashboardStates(searchText?: string): Observable> { + this.targetDashboardStateSearchText = searchText; + if (this.widgetActionFormGroup.get('type').value === WidgetActionType.openDashboard) { + return this.selectedDashboardStateIds.pipe( + map(stateIds => { + const result = searchText ? stateIds.filter(this.createFilterForDashboardState(searchText)) : stateIds; + if (result && result.length) { + return result; + } else { + return [searchText]; + } + }) + ); + } else { + return of(this.callbacks.fetchDashboardStates(searchText)); + } + } + + private createFilterForDashboardState(query: string): (stateId: string) => boolean { + const lowercaseQuery = query.toLowerCase(); + return stateId => stateId.toLowerCase().indexOf(lowercaseQuery) === 0; + } + + private getStateDisplayType(action?: WidgetAction): stateDisplayType { + let res: stateDisplayType = 'normal'; + if (action) { + if (action.openInSeparateDialog) { + res = 'separateDialog'; + } else if (action.openInPopover) { + res = 'popover'; + } + } + return res; + } + + private widgetActionUpdated() { + const type: WidgetActionType = this.widgetActionFormGroup.get('type').value; + let result: WidgetAction; + if (type === WidgetActionType.customPretty) { + result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.get('customAction').value}; + } else { + result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value}; + } + if (this.actionTypeFormGroup.get('stateDisplayType') && + this.actionTypeFormGroup.get('stateDisplayType').value !== 'normal') { + result = {...result, ...this.stateDisplayTypeFormGroup.value}; + result.openInSeparateDialog = this.actionTypeFormGroup.get('stateDisplayType').value === 'separateDialog'; + result.openInPopover = this.actionTypeFormGroup.get('stateDisplayType').value === 'popover'; + } else { + result.openInSeparateDialog = false; + result.openInPopover = false; + } + delete (result as any).stateDisplayType; + this.propagateChange(result); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.html new file mode 100644 index 0000000000..a88ea77488 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.html @@ -0,0 +1,49 @@ + +
+
date.time-granularity-formats
+
+
+
{{ formatTimeUnitTranslations.get(unit) | translate }}
+
+ + +
+
+ +
{{ previewText[unit] }}
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.scss new file mode 100644 index 0000000000..be50f87af8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.scss @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../scss/constants'; + +.tb-auto-date-format-settings-panel { + width: 620px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-auto-date-format-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-auto-date-format-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-form-row { + .fixed-title-width { + min-width: 120px; + } + .mat-mdc-form-field.tb-date-format-input { + .mat-mdc-text-field-wrapper.mdc-text-field--outlined { + .mat-mdc-form-field-flex { + .mat-mdc-form-field-icon-suffix { + display: flex; + align-items: center; + line-height: normal; + } + } + } + } + .preview-text { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.38); + } + } + .tb-auto-date-format-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.ts new file mode 100644 index 0000000000..dcfc942778 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.ts @@ -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 { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { + AutoDateFormatSettings, defaultAutoDateFormatSettings, + FormatTimeUnit, + formatTimeUnits, + formatTimeUnitTranslations +} from '@shared/models/widget-settings.models'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DatePipe } from '@angular/common'; + +@Component({ + selector: 'tb-auto-date-format-settings-panel', + templateUrl: './auto-date-format-settings-panel.component.html', + providers: [], + styleUrls: ['./auto-date-format-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class AutoDateFormatSettingsPanelComponent extends PageComponent implements OnInit { + + formatTimeUnits = formatTimeUnits; + + formatTimeUnitTranslations = formatTimeUnitTranslations; + + @Input() + autoDateFormatSettings: AutoDateFormatSettings; + + @Input() + defaultValues = defaultAutoDateFormatSettings; + + @Input() + popover: TbPopoverComponent; + + @Output() + autoDateFormatSettingsApplied = new EventEmitter(); + + autoDateFormatFormGroup: UntypedFormGroup; + + previewText: {[unit in FormatTimeUnit]: string} = {} as any; + + constructor(private date: DatePipe, + private fb: UntypedFormBuilder, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.autoDateFormatFormGroup = this.fb.group({}); + for (const unit of formatTimeUnits) { + this.autoDateFormatFormGroup.addControl(unit, + this.fb.control(this.autoDateFormatSettings[unit] || this.defaultValues[unit], [Validators.required])); + this.autoDateFormatFormGroup.get(unit).valueChanges.subscribe((value: string) => { + this.previewText[unit] = this.date.transform(Date.now(), value); + }); + this.previewText[unit] = this.date.transform(Date.now(), this.autoDateFormatSettings[unit] || this.defaultValues[unit]); + } + } + + cancel() { + this.popover?.hide(); + } + + applyAutoDateFormatSettings() { + const autoDateFormatSettings: AutoDateFormatSettings = this.autoDateFormatFormGroup.value; + for (const unit of formatTimeUnits) { + if (autoDateFormatSettings[unit] === this.defaultValues[unit]) { + delete autoDateFormatSettings[unit]; + } + } + this.autoDateFormatSettingsApplied.emit(autoDateFormatSettings); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings.component.html new file mode 100644 index 0000000000..864ad3047c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings.component.html @@ -0,0 +1,25 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings.component.ts new file mode 100644 index 0000000000..cf5b9033fa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings.component.ts @@ -0,0 +1,99 @@ +/// +/// 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, OnInit, Renderer2, ViewContainerRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { AutoDateFormatSettings, defaultAutoDateFormatSettings } from '@shared/models/widget-settings.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { deepClone, mergeDeep } from '@core/utils'; +import { + AutoDateFormatSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/auto-date-format-settings-panel.component'; + +@Component({ + selector: 'tb-auto-date-format-settings', + templateUrl: './auto-date-format-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AutoDateFormatSettingsComponent), + multi: true + } + ] +}) +export class AutoDateFormatSettingsComponent implements OnInit, ControlValueAccessor { + + @Input() + disabled: boolean; + + @Input() + defaultValues = defaultAutoDateFormatSettings; + + private modelValue: AutoDateFormatSettings; + + private propagateChange = null; + + constructor(private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef) {} + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: AutoDateFormatSettings): void { + this.modelValue = value; + } + + openAutoFormatSettingsPopup($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 = { + autoDateFormatSettings: deepClone(this.modelValue), + defaultValues: this.defaultValues + }; + const autoDateFormatSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, AutoDateFormatSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + autoDateFormatSettingsPanelPopover.tbComponentRef.instance.popover = autoDateFormatSettingsPanelPopover; + autoDateFormatSettingsPanelPopover.tbComponentRef.instance.autoDateFormatSettingsApplied.subscribe((autoDateFormatSettings) => { + autoDateFormatSettingsPanelPopover.hide(); + this.modelValue = autoDateFormatSettings; + this.propagateChange(this.modelValue); + }); + } + } + +} 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..9038f2a22f 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
- - - {{ backgroundTypeTranslationsMap.get(type) | translate }} - - -
- -
-
widgets.background.image-url
- - - -
-
-
widgets.color.color
- - -
-
-
-
widgets.background.overlay
- - {{ 'widgets.background.enable-overlay' | translate }} - -
-
widgets.color.color
- - -
-
-
widgets.background.blur
- - -
px
-
+
+
+
+
widgets.background.background
+ + + {{ backgroundTypeTranslationsMap.get(type) | translate }} + + +
+ +
+
widgets.color.color
+ + +
-
-
-
- widgets.background.preview +
+
widgets.background.overlay
+ + {{ 'widgets.background.enable-overlay' | translate }} + +
+
widgets.color.color
+ + +
+
+
widgets.background.blur
+ + +
px
+
+
-
-
+
+
+ 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..6bc83fe967 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); @@ -62,6 +67,14 @@ left: 7.998px; right: 7.998px; } + .tb-background-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } .tb-background-settings-panel-buttons { height: 40px; display: flex; 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 @@ +
+ + + {{ widgetButtonTypeTranslationMap.get(type) | translate }} + + +
+ + {{ 'widgets.button.auto-scale' | translate }} + +
+
+ + {{ 'widgets.button.label' | translate }} + + + + +
+
+ + {{ 'widgets.button.icon' | translate }} + +
+ + + + + + +
+
+
+
{{ 'widgets.button.border-radius' | translate }}
+ +
+
+
{{ 'widgets.button.color-palette' | translate }}
+
+
+
widgets.button.main
+ + +
+ +
+
widgets.button.background
+ + +
+
+
+
+ + + +
widgets.button.custom-styles
+
+
+ +
+
{{ widgetButtonStateTranslationMap.get(state) | translate }}
+ + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.ts new file mode 100644 index 0000000000..f9afc0b5d1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.ts @@ -0,0 +1,158 @@ +/// +/// 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, OnInit, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { + WidgetButtonAppearance, + widgetButtonStates, widgetButtonStatesTranslations, + widgetButtonTypeImages, + widgetButtonTypes, + widgetButtonTypeTranslations +} from '@shared/components/button/widget-button.models'; +import { merge } from 'rxjs'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-widget-button-appearance', + templateUrl: './widget-button-appearance.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetButtonAppearanceComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class WidgetButtonAppearanceComponent implements OnInit, ControlValueAccessor { + + @Input() + disabled = false; + + @Input() + borderRadius: string; + + @Input() + autoScale: boolean; + + @Input() + @coerceBoolean() + withAutoScale = true; + + @Input() + @coerceBoolean() + withBorderRadius = false; + + widgetButtonTypes = widgetButtonTypes; + + widgetButtonTypeTranslationMap = widgetButtonTypeTranslations; + widgetButtonTypeImageMap = widgetButtonTypeImages; + + widgetButtonStates = widgetButtonStates; + widgetButtonStateTranslationMap = widgetButtonStatesTranslations; + + modelValue: WidgetButtonAppearance; + + appearanceFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder) {} + + ngOnInit(): void { + this.appearanceFormGroup = this.fb.group({ + type: [null, []], + showLabel: [null, []], + label: [null, []], + showIcon: [null, []], + icon: [null, []], + iconSize: [null, []], + iconSizeUnit: [null, []], + mainColor: [null, []], + backgroundColor: [null, []] + }); + if (this.withAutoScale) { + this.appearanceFormGroup.addControl('autoScale', this.fb.control(null, [])); + } + if (this.withBorderRadius) { + this.appearanceFormGroup.addControl('borderRadius', this.fb.control(null, [])); + } + const customStyle = this.fb.group({}); + for (const state of widgetButtonStates) { + customStyle.addControl(state, this.fb.control(null, [])); + } + this.appearanceFormGroup.addControl('customStyle', customStyle); + this.appearanceFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + merge(this.appearanceFormGroup.get('showLabel').valueChanges, + this.appearanceFormGroup.get('showIcon').valueChanges) + .subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.appearanceFormGroup.disable({emitEvent: false}); + } else { + this.appearanceFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: WidgetButtonAppearance): void { + this.modelValue = value; + this.appearanceFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateModel() { + this.modelValue = this.appearanceFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } + + private updateValidators(): void { + const showLabel: boolean = this.appearanceFormGroup.get('showLabel').value; + const showIcon: boolean = this.appearanceFormGroup.get('showIcon').value; + if (showLabel) { + this.appearanceFormGroup.get('label').enable(); + } else { + this.appearanceFormGroup.get('label').disable(); + } + if (showIcon) { + this.appearanceFormGroup.get('icon').enable(); + this.appearanceFormGroup.get('iconSize').enable(); + this.appearanceFormGroup.get('iconSizeUnit').enable(); + } else { + this.appearanceFormGroup.get('icon').disable(); + this.appearanceFormGroup.get('iconSize').disable(); + this.appearanceFormGroup.get('iconSizeUnit').disable(); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.html new file mode 100644 index 0000000000..86b5acde6e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.html @@ -0,0 +1,95 @@ + +
+
{{ widgetButtonStateTranslationMap.get(state) | translate }}
+
+
+ + {{ 'widgets.button.main' | translate }} + + + +
+
+ + {{ 'widgets.button.background' | translate }} + + + +
+
+ + {{ 'widgets.button.shadow' | translate }} + + + {{ 'widgets.button.enabled' | translate }} + {{ 'widgets.button.disabled' | translate }} + +
+
+
+ widgets.button.preview +
+ + +
+
+
+ + + +
+ +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.scss new file mode 100644 index 0000000000..dfbbb91620 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.scss @@ -0,0 +1,68 @@ +/** + * 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-widget-button-custom-style-panel { + width: 530px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-widget-button-custom-style-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + } + .tb-widget-button-custom-style-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-widget-button-custom-style-preview { + flex: 1; + background: rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + padding: 12px 16px 24px 16px; + align-items: center; + gap: 12px; + .tb-widget-button-custom-style-preview-title { + align-self: stretch; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; + color: rgba(0, 0, 0, 0.38); + } + tb-widget-button { + width: 200px; + height: 60px; + } + } + .tb-widget-button-custom-style-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.ts new file mode 100644 index 0000000000..5bb043b9d8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.ts @@ -0,0 +1,198 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +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 { + defaultBackgroundColorDisabled, + defaultMainColorDisabled, + WidgetButtonAppearance, + WidgetButtonCustomStyle, + WidgetButtonState, + widgetButtonStates, + widgetButtonStatesTranslations, + WidgetButtonType +} from '@shared/components/button/widget-button.models'; +import { merge } from 'rxjs'; +import { deepClone } from '@core/utils'; +import { WidgetButtonComponent } from '@shared/components/button/widget-button.component'; + +@Component({ + selector: 'tb-widget-button-custom-style-panel', + templateUrl: './widget-button-custom-style-panel.component.html', + providers: [], + styleUrls: ['./widget-button-custom-style-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class WidgetButtonCustomStylePanelComponent extends PageComponent implements OnInit { + + @ViewChild('widgetButtonPreview') + widgetButtonPreview: WidgetButtonComponent; + + @Input() + appearance: WidgetButtonAppearance; + + @Input() + borderRadius: string; + + @Input() + autoScale: boolean; + + @Input() + state: WidgetButtonState; + + @Input() + customStyle: WidgetButtonCustomStyle; + + private popoverValue: TbPopoverComponent; + + @Input() + set popover(popover: TbPopoverComponent) { + this.popoverValue = popover; + popover.tbAnimationDone.subscribe(() => { + this.widgetButtonPreview?.validateSize(); + }); + } + + get popover(): TbPopoverComponent { + return this.popoverValue; + } + + @Output() + customStyleApplied = new EventEmitter(); + + widgetButtonStateTranslationMap = widgetButtonStatesTranslations; + + widgetButtonState = WidgetButtonState; + + previewAppearance: WidgetButtonAppearance; + + copyFromStates: WidgetButtonState[]; + + customStyleFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + protected store: Store, + private cd: ChangeDetectorRef) { + super(store); + } + + ngOnInit(): void { + this.copyFromStates = widgetButtonStates.filter(state => + state !== this.state && !!this.appearance.customStyle[state]); + this.customStyleFormGroup = this.fb.group( + { + overrideMainColor: [false, []], + mainColor: [null, []], + overrideBackgroundColor: [false, []], + backgroundColor: [null, []], + overrideDropShadow: [false, []], + dropShadow: [false, []] + } + ); + merge(this.customStyleFormGroup.get('overrideMainColor').valueChanges, + this.customStyleFormGroup.get('overrideBackgroundColor').valueChanges, + this.customStyleFormGroup.get('overrideDropShadow').valueChanges) + .subscribe(() => { + this.updateValidators(); + }); + this.customStyleFormGroup.valueChanges.subscribe(() => { + this.updatePreviewAppearance(); + }); + this.setStyle(this.customStyle); + } + + copyStyle(state: WidgetButtonState) { + this.customStyle = deepClone(this.appearance.customStyle[state]); + this.setStyle(this.customStyle); + this.customStyleFormGroup.markAsDirty(); + } + + cancel() { + this.popover?.hide(); + } + + applyCustomStyle() { + const customStyle: WidgetButtonCustomStyle = this.customStyleFormGroup.value; + this.customStyleApplied.emit(customStyle); + } + + private setStyle(customStyle?: WidgetButtonCustomStyle): void { + let mainColor = this.state === WidgetButtonState.disabled ? defaultMainColorDisabled : this.appearance.mainColor; + if (customStyle?.overrideMainColor) { + mainColor = customStyle?.mainColor; + } + let backgroundColor = this.state === WidgetButtonState.disabled ? defaultBackgroundColorDisabled : this.appearance.backgroundColor; + if (customStyle?.overrideBackgroundColor) { + backgroundColor = customStyle?.backgroundColor; + } + let dropShadow = this.appearance.type !== WidgetButtonType.basic; + if (customStyle?.overrideDropShadow) { + dropShadow = customStyle?.dropShadow; + } + this.customStyleFormGroup.patchValue({ + overrideMainColor: customStyle?.overrideMainColor, + mainColor, + overrideBackgroundColor: customStyle?.overrideBackgroundColor, + backgroundColor, + overrideDropShadow: customStyle?.overrideDropShadow, + dropShadow + }, {emitEvent: false}); + this.updateValidators(); + this.updatePreviewAppearance(); + } + + private updateValidators() { + const overrideMainColor: boolean = this.customStyleFormGroup.get('overrideMainColor').value; + const overrideBackgroundColor: boolean = this.customStyleFormGroup.get('overrideBackgroundColor').value; + const overrideDropShadow: boolean = this.customStyleFormGroup.get('overrideDropShadow').value; + + if (overrideMainColor) { + this.customStyleFormGroup.get('mainColor').enable({emitEvent: false}); + } else { + this.customStyleFormGroup.get('mainColor').disable({emitEvent: false}); + } + if (overrideBackgroundColor) { + this.customStyleFormGroup.get('backgroundColor').enable({emitEvent: false}); + } else { + this.customStyleFormGroup.get('backgroundColor').disable({emitEvent: false}); + } + if (overrideDropShadow) { + this.customStyleFormGroup.get('dropShadow').enable({emitEvent: false}); + } else { + this.customStyleFormGroup.get('dropShadow').disable({emitEvent: false}); + } + } + + private updatePreviewAppearance() { + this.previewAppearance = deepClone(this.appearance); + this.previewAppearance.customStyle[this.state] = this.customStyleFormGroup.value; + this.cd.markForCheck(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.html new file mode 100644 index 0000000000..dd2f43afa9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.html @@ -0,0 +1,45 @@ + +
+
+ + + +
+ +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.scss new file mode 100644 index 0000000000..d7cdeffec5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.scss @@ -0,0 +1,46 @@ +/** + * 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-widget-button-custom-style { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + button.mat-mdc-icon-button { + color: rgba(0,0,0,0.56); + } + .tb-widget-button-preview-panel { + width: 148px; + height: 48px; + padding: 8px 12px; + border-radius: 4px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + tb-widget-button { + width: 84px; + height: 100%; + } + @media #{$mat-gt-xs} { + width: 168px; + tb-widget-button { + width: 104px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.ts new file mode 100644 index 0000000000..e766197dc5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.ts @@ -0,0 +1,162 @@ +/// +/// 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, + OnChanges, + OnInit, + Renderer2, + SimpleChanges, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { + WidgetButtonAppearance, + WidgetButtonCustomStyle, + WidgetButtonState +} from '@shared/components/button/widget-button.models'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MatIconButton } from '@angular/material/button'; +import { + WidgetButtonCustomStylePanelComponent +} from '@home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-widget-button-custom-style', + templateUrl: './widget-button-custom-style.component.html', + styleUrls: ['./widget-button-custom-style.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetButtonCustomStyleComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class WidgetButtonCustomStyleComponent implements OnInit, OnChanges, ControlValueAccessor { + + @Input() + disabled = false; + + @Input() + appearance: WidgetButtonAppearance; + + @Input() + borderRadius: string; + + @Input() + autoScale: boolean; + + @Input() + state: WidgetButtonState; + + widgetButtonState = WidgetButtonState; + + modelValue: WidgetButtonCustomStyle; + + previewAppearance: WidgetButtonAppearance; + + private propagateChange = (_val: any) => {}; + + constructor(private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) {} + + ngOnInit(): void { + this.updatePreviewAppearance(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange) { + if (propName === 'appearance') { + this.updatePreviewAppearance(); + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(_isDisabled: boolean): void { + } + + writeValue(value: WidgetButtonCustomStyle): void { + this.modelValue = value; + this.updatePreviewAppearance(); + } + + clearStyle() { + this.updateModel(null); + } + + openButtonCustomStylePopup($event: Event, matButton: MatIconButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + appearance: this.appearance, + borderRadius: this.borderRadius, + autoScale: this.autoScale, + state: this.state, + customStyle: this.modelValue + }; + const widgetButtonCustomStylePanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, WidgetButtonCustomStylePanelComponent, + ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + widgetButtonCustomStylePanelPopover.tbComponentRef.instance.popover = widgetButtonCustomStylePanelPopover; + widgetButtonCustomStylePanelPopover.tbComponentRef.instance.customStyleApplied.subscribe((customStyle) => { + widgetButtonCustomStylePanelPopover.hide(); + this.updateModel(customStyle); + }); + } + } + + private updateModel(value: WidgetButtonCustomStyle): void { + this.modelValue = value; + this.updatePreviewAppearance(); + this.propagateChange(this.modelValue); + } + + private updatePreviewAppearance() { + this.previewAppearance = deepClone(this.appearance); + if (this.modelValue) { + this.previewAppearance.customStyle[this.state] = this.modelValue; + } + this.cd.markForCheck(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-animation-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-animation-settings.component.html new file mode 100644 index 0000000000..5e0ad8e2ec --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-animation-settings.component.html @@ -0,0 +1,89 @@ + + +
+ + + + + {{ 'widgets.time-series-chart.animation.animation' | translate }} + + + + +
+
{{ 'widgets.time-series-chart.animation.animation-threshold' | translate }}
+ + +
ms
+
+
+
+
{{ 'widgets.time-series-chart.animation.animation-duration' | translate }}
+ + +
ms
+
+
+
+
widgets.time-series-chart.animation.animation-easing
+ + + + {{ easing }} + + + +
+
+
{{ 'widgets.time-series-chart.animation.animation-delay' | translate }}
+ + +
ms
+
+
+
+
{{ 'widgets.time-series-chart.animation.update-animation-duration' | translate }}
+ + +
ms
+
+
+
+
widgets.time-series-chart.animation.update-animation-easing
+ + + + {{ easing }} + + + +
+
+
{{ 'widgets.time-series-chart.animation.update-animation-delay' | translate }}
+ + +
ms
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-animation-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-animation-settings.component.ts new file mode 100644 index 0000000000..5da5aa2fe9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-animation-settings.component.ts @@ -0,0 +1,124 @@ +/// +/// 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, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + timeSeriesChartAnimationEasings, + TimeSeriesChartAnimationSettings +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-time-series-chart-animation-settings', + templateUrl: './time-series-chart-animation-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartAnimationSettingsComponent), + multi: true + } + ] +}) +export class TimeSeriesChartAnimationSettingsComponent implements OnInit, ControlValueAccessor { + + settingsExpanded = false; + + timeSeriesChartAnimationEasings = timeSeriesChartAnimationEasings; + + @Input() + disabled: boolean; + + private modelValue: TimeSeriesChartAnimationSettings; + + private propagateChange = null; + + public animationSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService,) { + } + + ngOnInit(): void { + this.animationSettingsFormGroup = this.fb.group({ + animation: [null, []], + animationThreshold: [null, [Validators.min(0)]], + animationDuration: [null, [Validators.min(0)]], + animationEasing: [null, []], + animationDelay: [null, [Validators.min(0)]], + animationDurationUpdate: [null, [Validators.min(0)]], + animationEasingUpdate: [null, []], + animationDelayUpdate: [null, [Validators.min(0)]] + }); + this.animationSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + this.animationSettingsFormGroup.get('animation').valueChanges + .subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.animationSettingsFormGroup.disable({emitEvent: false}); + } else { + this.animationSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: TimeSeriesChartAnimationSettings): void { + this.modelValue = value; + this.animationSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.animationSettingsFormGroup.get('animation').valueChanges.subscribe((animation) => { + this.settingsExpanded = animation; + }); + } + + private updateValidators() { + const animation: boolean = this.animationSettingsFormGroup.get('animation').value; + if (animation) { + this.animationSettingsFormGroup.enable({emitEvent: false}); + } else { + this.animationSettingsFormGroup.disable({emitEvent: false}); + this.animationSettingsFormGroup.get('animation').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.animationSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html new file mode 100644 index 0000000000..1d4bb95f5c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html @@ -0,0 +1,171 @@ + + +
+ + + + + {{ 'widgets.time-series-chart.axis.show' | translate }} + + + + +
+
widgets.time-series-chart.axis.label
+
+ + + + + + + +
+
+
+
widgets.time-series-chart.axis.position
+ + + + {{ timeSeriesAxisPositionTranslations.get(position) | translate }} + + + +
+
+
widget-config.units-short
+ + +
+
+
widget-config.decimals-short
+ + + +
+
+
+ +
widgets.time-series-chart.axis.tick-labels
+
+
+ + + + + + +
+
+
+ + +
+
+
+ + {{ 'widgets.time-series-chart.axis.show-ticks' | translate }} + + + +
+
+ + {{ 'widgets.time-series-chart.axis.show-line' | translate }} + + + +
+
+ +
+ {{ 'widgets.time-series-chart.axis.show-split-lines' | translate }} +
+
+ + +
+
+
+ {{ 'widgets.time-series-chart.axis.ticks-interval' | translate }} +
+ + + +
+
+
+ {{ 'widgets.time-series-chart.axis.split-number' | translate }} +
+ + + +
+
+
+
+
+
widgets.time-series-chart.axis.scale
+
+
widgets.time-series-chart.axis.scale-min
+ + + +
widgets.time-series-chart.axis.scale-max
+ + + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts new file mode 100644 index 0000000000..8dafc7050f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts @@ -0,0 +1,212 @@ +/// +/// 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, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + AxisPosition, defaultXAxisTicksFormat, + timeSeriesAxisPositionTranslations, + TimeSeriesChartAxisSettings, TimeSeriesChartXAxisSettings, + TimeSeriesChartYAxisSettings +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { merge } from 'rxjs'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-time-series-chart-axis-settings', + templateUrl: './time-series-chart-axis-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartAxisSettingsComponent), + multi: true + } + ] +}) +export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValueAccessor { + + @Input() + @coerceBoolean() + alwaysExpanded = false; + + settingsExpanded = false; + + axisPositions: AxisPosition[]; + + timeSeriesAxisPositionTranslations = timeSeriesAxisPositionTranslations; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + defaultXAxisTicksFormat = defaultXAxisTicksFormat; + + @Input() + disabled: boolean; + + @Input() + axisType: 'xAxis' | 'yAxis' = 'xAxis'; + + @Input() + @coerceBoolean() + advanced = false; + + private modelValue: TimeSeriesChartXAxisSettings | TimeSeriesChartYAxisSettings; + + private propagateChange = null; + + public axisSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService,) { + } + + ngOnInit(): void { + + this.axisPositions = this.axisType === 'xAxis' ? [AxisPosition.top, AxisPosition.bottom] : + [AxisPosition.left, AxisPosition.right]; + + this.axisSettingsFormGroup = this.fb.group({ + show: [null, []], + label: [null, []], + labelFont: [null, []], + labelColor: [null, []], + position: [null, []], + showTickLabels: [null, []], + tickLabelFont: [null, []], + tickLabelColor: [null, []], + showTicks: [null, []], + ticksColor: [null, []], + showLine: [null, []], + lineColor: [null, []], + showSplitLines: [null, []], + splitLinesColor: [null, []] + }); + if (this.axisType === 'yAxis') { + this.axisSettingsFormGroup.addControl('units', this.fb.control(null, [])); + this.axisSettingsFormGroup.addControl('decimals', this.fb.control(null, [Validators.min(0)])); + this.axisSettingsFormGroup.addControl('ticksFormatter', this.fb.control(null, [])); + this.axisSettingsFormGroup.addControl('interval', this.fb.control(null, [Validators.min(0)])); + this.axisSettingsFormGroup.addControl('splitNumber', this.fb.control(null, [Validators.min(1)])); + this.axisSettingsFormGroup.addControl('min', this.fb.control(null, [])); + this.axisSettingsFormGroup.addControl('max', this.fb.control(null, [])); + } else if (this.axisType === 'xAxis') { + this.axisSettingsFormGroup.addControl('ticksFormat', this.fb.control(null, [])); + } + this.axisSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + merge(this.axisSettingsFormGroup.get('show').valueChanges, + this.axisSettingsFormGroup.get('showTickLabels').valueChanges, + this.axisSettingsFormGroup.get('showTicks').valueChanges, + this.axisSettingsFormGroup.get('showLine').valueChanges, + this.axisSettingsFormGroup.get('showSplitLines').valueChanges) + .subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.axisSettingsFormGroup.disable({emitEvent: false}); + } else { + this.axisSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: TimeSeriesChartAxisSettings | TimeSeriesChartYAxisSettings): void { + this.modelValue = value; + this.axisSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.axisSettingsFormGroup.get('show').valueChanges.subscribe((show) => { + this.settingsExpanded = show; + }); + } + + private updateValidators() { + const show: boolean = this.axisSettingsFormGroup.get('show').value; + const showTickLabels: boolean = this.axisSettingsFormGroup.get('showTickLabels').value; + const showTicks: boolean = this.axisSettingsFormGroup.get('showTicks').value; + const showLine: boolean = this.axisSettingsFormGroup.get('showLine').value; + const showSplitLines: boolean = this.axisSettingsFormGroup.get('showSplitLines').value; + if (show) { + this.axisSettingsFormGroup.enable({emitEvent: false}); + if (showTickLabels) { + this.axisSettingsFormGroup.get('tickLabelFont').enable({emitEvent: false}); + this.axisSettingsFormGroup.get('tickLabelColor').enable({emitEvent: false}); + if (this.axisType === 'yAxis') { + this.axisSettingsFormGroup.get('ticksFormatter').enable({emitEvent: false}); + } + if (this.axisType === 'xAxis') { + this.axisSettingsFormGroup.get('ticksFormat').enable({emitEvent: false}); + } + } else { + this.axisSettingsFormGroup.get('tickLabelFont').disable({emitEvent: false}); + this.axisSettingsFormGroup.get('tickLabelColor').disable({emitEvent: false}); + if (this.axisType === 'yAxis') { + this.axisSettingsFormGroup.get('ticksFormatter').disable({emitEvent: false}); + } + if (this.axisType === 'xAxis') { + this.axisSettingsFormGroup.get('ticksFormat').disable({emitEvent: false}); + } + } + if (showTicks) { + this.axisSettingsFormGroup.get('ticksColor').enable({emitEvent: false}); + } else { + this.axisSettingsFormGroup.get('ticksColor').disable({emitEvent: false}); + } + if (showLine) { + this.axisSettingsFormGroup.get('lineColor').enable({emitEvent: false}); + } else { + this.axisSettingsFormGroup.get('lineColor').disable({emitEvent: false}); + } + if (showSplitLines) { + this.axisSettingsFormGroup.get('splitLinesColor').enable({emitEvent: false}); + } else { + this.axisSettingsFormGroup.get('splitLinesColor').disable({emitEvent: false}); + } + } else { + this.axisSettingsFormGroup.disable({emitEvent: false}); + this.axisSettingsFormGroup.get('show').enable({emitEvent: false}); + if (this.axisType === 'yAxis') { + this.axisSettingsFormGroup.get('min').enable({emitEvent: false}); + this.axisSettingsFormGroup.get('max').enable({emitEvent: false}); + } + } + } + + private updateModel() { + this.modelValue = this.axisSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html new file mode 100644 index 0000000000..7e2c9f74c2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html @@ -0,0 +1,117 @@ + +
+
+ + + + {{ timeSeriesThresholdTypeTranslations.get(type) | translate }} + + + + + +
+
+ + + + warning + + + + + + +
+
+ + + + {{ yAxis }} + + + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.scss new file mode 100644 index 0000000000..2ee42db765 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.scss @@ -0,0 +1,101 @@ +/** + * 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-form-table-row.tb-time-series-threshold-row { + @media #{$mat-lt-md} { + align-items: flex-start; + } + .tb-threshold-source-field { + flex: 1; + min-width: 100px; + display: flex; + flex-direction: column; + gap: 8px; + @media #{$mat-gt-sm} { + flex-direction: row; + align-items: center; + gap: 12px; + } + @media #{$mat-gt-md} { + min-width: 200px; + } + } + + .tb-threshold-type-field { + flex: 1; + } + + .tb-entity-alias-input { + flex: 1; + } + + .tb-threshold-key-value-field { + flex: 1; + min-width: 100px; + .tb-inline-field { + width: 100%; + } + @media #{$mat-gt-md} { + min-width: 150px; + } + } + + .tb-y-axis-field { + width: 100px; + min-width: 100px; + .mat-mdc-form-field { + width: 100%; + } + } + + .tb-color-field, .tb-units-field, .tb-decimals-field { + display: flex; + flex-direction: row; + place-content: center; + align-items: center; + } + + .tb-units-field { + width: 80px; + min-width: 80px; + } + + .tb-color-field { + width: 40px; + min-width: 40px; + } + + .tb-decimals-field { + width: 60px; + min-width: 60px; + } + + .tb-y-axis-field { + @media #{$mat-xs} { + display: none; + } + } + + .tb-units-field, .tb-color-field, .tb-decimals-field { + display: none; + @media #{$mat-gt-md} { + display: flex; + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts new file mode 100644 index 0000000000..baebf8c697 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts @@ -0,0 +1,287 @@ +/// +/// 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, + EventEmitter, + forwardRef, + Input, OnChanges, + OnInit, + Output, + Renderer2, SimpleChanges, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + TimeSeriesChartThreshold, + TimeSeriesChartThresholdType, TimeSeriesChartYAxisId, + timeSeriesThresholdTypes, + timeSeriesThresholdTypeTranslations +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { + TimeSeriesChartThresholdsPanelComponent +} from '@home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component'; +import { IAliasController } from '@core/api/widget-api.models'; +import { DataKey, Datasource, DatasourceType, WidgetConfig } from '@shared/models/widget.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { deepClone } from '@core/utils'; +import { + TimeSeriesChartThresholdSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component'; + +@Component({ + selector: 'tb-time-series-chart-threshold-row', + templateUrl: './time-series-chart-threshold-row.component.html', + styleUrls: ['./time-series-chart-threshold-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartThresholdRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class TimeSeriesChartThresholdRowComponent implements ControlValueAccessor, OnInit, OnChanges { + + DataKeyType = DataKeyType; + + DatasourceType = DatasourceType; + + TimeSeriesChartThresholdType = TimeSeriesChartThresholdType; + + timeSeriesThresholdTypes = timeSeriesThresholdTypes; + + timeSeriesThresholdTypeTranslations = timeSeriesThresholdTypeTranslations; + + get aliasController(): IAliasController { + return this.thresholdsPanel.aliasController; + } + + get dataKeyCallbacks(): DataKeysCallbacks { + return this.thresholdsPanel.dataKeyCallbacks; + } + + get datasource(): Datasource { + return this.thresholdsPanel.datasource; + } + + get widgetConfig(): WidgetConfig { + return this.thresholdsPanel.widgetConfig; + } + + @Input() + disabled: boolean; + + @Input() + yAxisIds: TimeSeriesChartYAxisId[]; + + @Output() + thresholdRemoved = new EventEmitter(); + + thresholdFormGroup: UntypedFormGroup; + + modelValue: TimeSeriesChartThreshold; + + latestKeyFormControl: UntypedFormControl; + + entityKeyFormControl: UntypedFormControl; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private thresholdsPanel: TimeSeriesChartThresholdsPanelComponent, + private cd: ChangeDetectorRef) { + } + + ngOnInit() { + this.thresholdFormGroup = this.fb.group({ + type: [null, []], + value: [null, [Validators.required]], + entityAlias: [null, [Validators.required]], + yAxisId: [null, [Validators.required]], + lineColor: [null, []], + units: [null, []], + decimals: [null, []] + }); + this.latestKeyFormControl = this.fb.control(null, [Validators.required]); + this.entityKeyFormControl = this.fb.control(null, [Validators.required]); + this.thresholdFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + this.latestKeyFormControl.valueChanges.subscribe( + () => this.updateModel() + ); + this.entityKeyFormControl.valueChanges.subscribe( + () => this.updateModel() + ); + this.thresholdFormGroup.get('type').valueChanges.subscribe(() => { + this.updateValidators(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['yAxisIds'].includes(propName)) { + if (this.modelValue?.yAxisId && + !this.yAxisIds.includes(this.modelValue.yAxisId)) { + this.thresholdFormGroup.patchValue({yAxisId: 'default'}, {emitEvent: true}); + } + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.thresholdFormGroup.disable({emitEvent: false}); + this.latestKeyFormControl.disable({emitEvent: false}); + this.entityKeyFormControl.disable({emitEvent: false}); + } else { + this.thresholdFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: TimeSeriesChartThreshold): void { + this.modelValue = value; + this.thresholdFormGroup.patchValue( + { + type: value.type, + value: value.value, + entityAlias: value.entityAlias, + yAxisId: value.yAxisId, + lineColor: value.lineColor, + units: value.units, + decimals: value.decimals, + }, {emitEvent: false} + ); + if (value.type === TimeSeriesChartThresholdType.latestKey) { + this.latestKeyFormControl.patchValue({ + type: value.latestKeyType, + name: value.latestKey + }, {emitEvent: false}); + } else if (value.type === TimeSeriesChartThresholdType.entity) { + this.entityKeyFormControl.patchValue({ + type: value.entityKeyType, + name: value.entityKey + }, {emitEvent: false}); + } + this.updateValidators(); + this.cd.markForCheck(); + } + + editThreshold($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 = { + thresholdSettings: deepClone(this.modelValue), + widgetConfig: this.widgetConfig, + yAxisIds: this.yAxisIds + }; + const thresholdSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, TimeSeriesChartThresholdSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + thresholdSettingsPanelPopover.tbComponentRef.instance.popover = thresholdSettingsPanelPopover; + thresholdSettingsPanelPopover.tbComponentRef.instance.thresholdSettingsApplied.subscribe((thresholdSettings) => { + thresholdSettingsPanelPopover.hide(); + this.modelValue = {...this.modelValue, ...thresholdSettings}; + this.thresholdFormGroup.patchValue( + { + yAxisId: this.modelValue.yAxisId, + units: this.modelValue.units, + decimals: this.modelValue.decimals, + lineColor: this.modelValue.lineColor + }, + {emitEvent: false}); + this.propagateChange(this.modelValue); + }); + } + } + + private updateValidators() { + const type: TimeSeriesChartThresholdType = this.thresholdFormGroup.get('type').value; + if (type === TimeSeriesChartThresholdType.constant) { + this.thresholdFormGroup.get('value').enable({emitEvent: false}); + this.thresholdFormGroup.get('entityAlias').disable({emitEvent: false}); + this.latestKeyFormControl.disable({emitEvent: false}); + this.entityKeyFormControl.disable({emitEvent: false}); + } else if (type === TimeSeriesChartThresholdType.latestKey) { + this.thresholdFormGroup.get('value').disable({emitEvent: false}); + this.thresholdFormGroup.get('entityAlias').disable({emitEvent: false}); + this.latestKeyFormControl.enable({emitEvent: false}); + this.entityKeyFormControl.disable({emitEvent: false}); + } else if (type === TimeSeriesChartThresholdType.entity) { + this.thresholdFormGroup.get('value').disable({emitEvent: false}); + this.thresholdFormGroup.get('entityAlias').enable({emitEvent: false}); + this.latestKeyFormControl.disable({emitEvent: false}); + this.entityKeyFormControl.enable({emitEvent: false}); + } + } + + private updateModel() { + const value = this.thresholdFormGroup.value; + this.modelValue.type = value.type; + this.modelValue.value = value.value; + this.modelValue.entityAlias = value.entityAlias; + this.modelValue.yAxisId = value.yAxisId; + this.modelValue.lineColor = value.lineColor; + this.modelValue.units = value.units; + this.modelValue.decimals = value.decimals; + if (value.type === TimeSeriesChartThresholdType.latestKey) { + const latestKey: DataKey = this.latestKeyFormControl.value; + this.modelValue.latestKey = latestKey?.name; + this.modelValue.latestKeyType = (latestKey?.type as any); + } else if (value.type === TimeSeriesChartThresholdType.entity) { + const entityKey: DataKey = this.entityKeyFormControl.value; + this.modelValue.entityKey = entityKey?.name; + this.modelValue.entityKeyType = (entityKey?.type as any); + } + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.html new file mode 100644 index 0000000000..3437a578d6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.html @@ -0,0 +1,143 @@ + +
+
{{ 'widgets.time-series-chart.threshold.threshold-settings' | translate }}
+
+
+
widgets.time-series-chart.axis.y-axis
+ + + + {{ yAxis }} + + + +
+
+
widget-config.units-short
+ + +
+
+
widget-config.decimals-short
+ + + +
+
+ + {{ 'widgets.time-series-chart.threshold.label' | translate }} + +
+ + + + {{ timeSeriesThresholdLabelPositionTranslations.get(position) | translate }} + + + + + + + +
+
+
+
widgets.time-series-chart.threshold.line-appearance
+
+
widgets.time-series-chart.threshold.line-color
+ + +
+
+
widgets.time-series-chart.line-type
+ + + + {{ timeSeriesLineTypeTranslations.get(lineType) | translate }} + + + +
+
+
widgets.time-series-chart.line-width
+ + + +
+
+
widgets.time-series-chart.threshold.start-symbol
+
+ + + + {{ timeSeriesChartShapeTranslations.get(shape) | translate }} + + + +
widgets.time-series-chart.threshold.symbol-size
+ + + +
+
+
+
widgets.time-series-chart.threshold.end-symbol
+
+ + + + {{ timeSeriesChartShapeTranslations.get(shape) | translate }} + + + +
widgets.time-series-chart.threshold.symbol-size
+ + + +
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.scss new file mode 100644 index 0000000000..b65fa6ad65 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.scss @@ -0,0 +1,49 @@ +/** + * 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-threshold-settings-panel { + width: 530px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-threshold-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-threshold-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-threshold-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.ts new file mode 100644 index 0000000000..b9f16103bd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.ts @@ -0,0 +1,146 @@ +/// +/// 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 { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { + TimeSeriesChartShape, + timeSeriesChartShapes, + timeSeriesChartShapeTranslations, + TimeSeriesChartThreshold, TimeSeriesChartYAxisId, + timeSeriesLineTypes, + timeSeriesLineTypeTranslations, + timeSeriesThresholdLabelPositions, + timeSeriesThresholdLabelPositionTranslations +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { merge } from 'rxjs'; +import { WidgetConfig } from '@shared/models/widget.models'; +import { formatValue, isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-time-series-chart-threshold-settings-panel', + templateUrl: './time-series-chart-threshold-settings-panel.component.html', + providers: [], + styleUrls: ['./time-series-chart-threshold-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class TimeSeriesChartThresholdSettingsPanelComponent implements OnInit { + + timeSeriesLineTypes = timeSeriesLineTypes; + + timeSeriesLineTypeTranslations = timeSeriesLineTypeTranslations; + + timeSeriesChartShapes = timeSeriesChartShapes; + + timeSeriesChartShapeTranslations = timeSeriesChartShapeTranslations; + + timeSeriesThresholdLabelPositions = timeSeriesThresholdLabelPositions; + + timeSeriesThresholdLabelPositionTranslations = timeSeriesThresholdLabelPositionTranslations; + + labelPreviewFn = this._labelPreviewFn.bind(this); + + @Input() + thresholdSettings: Partial; + + @Input() + widgetConfig: WidgetConfig; + + @Input() + yAxisIds: TimeSeriesChartYAxisId[]; + + @Input() + popover: TbPopoverComponent; + + @Output() + thresholdSettingsApplied = new EventEmitter>(); + + thresholdSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit(): void { + this.thresholdSettingsFormGroup = this.fb.group( + { + yAxisId: [this.thresholdSettings.yAxisId, [Validators.required]], + units: [this.thresholdSettings.units, []], + decimals: [this.thresholdSettings.decimals, [Validators.min(0)]], + lineColor: [this.thresholdSettings.lineColor, []], + lineType: [this.thresholdSettings.lineType, []], + lineWidth: [this.thresholdSettings.lineWidth, [Validators.min(0)]], + startSymbol: [this.thresholdSettings.startSymbol, []], + startSymbolSize: [this.thresholdSettings.startSymbolSize, [Validators.min(0)]], + endSymbol: [this.thresholdSettings.endSymbol, []], + endSymbolSize: [this.thresholdSettings.endSymbolSize, [Validators.min(0)]], + showLabel: [this.thresholdSettings.showLabel, []], + labelPosition: [this.thresholdSettings.labelPosition, []], + labelFont: [this.thresholdSettings.labelFont, []], + labelColor: [this.thresholdSettings.labelColor, []] + } + ); + merge(this.thresholdSettingsFormGroup.get('showLabel').valueChanges, + this.thresholdSettingsFormGroup.get('startSymbol').valueChanges, + this.thresholdSettingsFormGroup.get('endSymbol').valueChanges).subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyThresholdSettings() { + const thresholdSettings = this.thresholdSettingsFormGroup.getRawValue(); + this.thresholdSettingsApplied.emit(thresholdSettings); + } + + private updateValidators() { + const showLabel: boolean = this.thresholdSettingsFormGroup.get('showLabel').value; + const startSymbol: TimeSeriesChartShape = this.thresholdSettingsFormGroup.get('startSymbol').value; + const endSymbol: TimeSeriesChartShape = this.thresholdSettingsFormGroup.get('endSymbol').value; + if (showLabel) { + this.thresholdSettingsFormGroup.get('labelPosition').enable({emitEvent: false}); + this.thresholdSettingsFormGroup.get('labelFont').enable({emitEvent: false}); + this.thresholdSettingsFormGroup.get('labelColor').enable({emitEvent: false}); + } else { + this.thresholdSettingsFormGroup.get('labelPosition').disable({emitEvent: false}); + this.thresholdSettingsFormGroup.get('labelFont').disable({emitEvent: false}); + this.thresholdSettingsFormGroup.get('labelColor').disable({emitEvent: false}); + } + if (startSymbol === TimeSeriesChartShape.none) { + this.thresholdSettingsFormGroup.get('startSymbolSize').disable({emitEvent: false}); + } else { + this.thresholdSettingsFormGroup.get('startSymbolSize').enable({emitEvent: false}); + } + if (endSymbol === TimeSeriesChartShape.none) { + this.thresholdSettingsFormGroup.get('endSymbolSize').disable({emitEvent: false}); + } else { + this.thresholdSettingsFormGroup.get('endSymbolSize').enable({emitEvent: false}); + } + } + + private _labelPreviewFn(): string { + let units: string = this.thresholdSettingsFormGroup.get('units').value; + units = units && units.length ? units : this.widgetConfig.units; + let decimals: number = this.thresholdSettingsFormGroup.get('decimals').value; + decimals = isDefinedAndNotNull(decimals) ? decimals : + (isDefinedAndNotNull(this.widgetConfig.decimals) ? this.widgetConfig.decimals : 2); + return formatValue(22, decimals, units, false); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html new file mode 100644 index 0000000000..e975eb4653 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html @@ -0,0 +1,50 @@ + +
+
{{ 'widgets.time-series-chart.threshold.thresholds' | translate }}
+
+
+
widgets.time-series-chart.threshold.source
+
widgets.time-series-chart.threshold.key-value
+
widgets.time-series-chart.axis.y-axis
+
widgets.color.color
+
widget-config.units-short
+
widget-config.decimals-short
+
+
+
+
+ + + +
+
+
+
+ +
+
+ + {{ 'widgets.time-series-chart.threshold.no-thresholds' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.scss new file mode 100644 index 0000000000..e59fb35a72 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.scss @@ -0,0 +1,85 @@ +/** + * 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-time-series-thresholds-panel { + .tb-form-table-header-cell { + &.tb-threshold-source-header { + flex: 1; + min-width: 100px; + @media #{$mat-gt-md} { + min-width: 200px; + } + } + &.tb-threshold-key-value-header { + flex: 1; + min-width: 100px; + @media #{$mat-gt-md} { + min-width: 150px; + } + } + + &.tb-y-axis-header { + width: 100px; + min-width: 100px; + } + + &.tb-units-header { + width: 80px; + min-width: 80px; + } + + &.tb-color-header { + width: 40px; + min-width: 40px; + } + + &.tb-decimals-header { + width: 60px; + min-width: 60px; + } + + &.tb-actions-header { + width: 80px; + min-width: 80px; + } + + &.tb-y-axis-header { + @media #{$mat-xs} { + display: none; + } + } + + &.tb-units-header, &.tb-color-header, &.tb-decimals-header { + display: none; + @media #{$mat-gt-md} { + display: block; + } + } + } + .tb-form-table-body { + .tb-time-series-threshold-row { + overflow: hidden; + } + .mat-divider { + margin-top: 8px; + @media #{$mat-gt-sm} { + display: none; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts new file mode 100644 index 0000000000..9cb5e5ced2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts @@ -0,0 +1,216 @@ +/// +/// 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, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { + TimeSeriesChartThreshold, + timeSeriesChartThresholdDefaultSettings, + TimeSeriesChartThresholdType, + timeSeriesChartThresholdValid, + timeSeriesChartThresholdValidator, TimeSeriesChartYAxisId +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { mergeDeep } from '@core/utils'; +import { IAliasController } from '@core/api/widget-api.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKey, Datasource, WidgetConfig } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; + +@Component({ + selector: 'tb-time-series-chart-thresholds-panel', + templateUrl: './time-series-chart-thresholds-panel.component.html', + styleUrls: ['./time-series-chart-thresholds-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartThresholdsPanelComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeSeriesChartThresholdsPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class TimeSeriesChartThresholdsPanelComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + @Input() + aliasController: IAliasController; + + @Input() + dataKeyCallbacks: DataKeysCallbacks; + + @Input() + datasource: Datasource; + + @Input() + widgetConfig: WidgetConfig; + + @Input() + yAxisIds: TimeSeriesChartYAxisId[]; + + thresholdsFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit() { + this.thresholdsFormGroup = this.fb.group({ + thresholds: [this.fb.array([]), []] + }); + this.thresholdsFormGroup.valueChanges.subscribe( + () => { + let thresholds: TimeSeriesChartThreshold[] = this.thresholdsFormGroup.get('thresholds').value; + if (thresholds) { + thresholds = thresholds.filter(t => timeSeriesChartThresholdValid(t)); + } + this.updateLatestDataKeys(thresholds); + this.propagateChange(thresholds); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.thresholdsFormGroup.disable({emitEvent: false}); + } else { + this.thresholdsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: TimeSeriesChartThreshold[] | undefined): void { + const thresholds = this.checkLatestDataKeys(value || []); + this.thresholdsFormGroup.setControl('thresholds', this.prepareThresholdsFormArray(thresholds), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.thresholdsFormGroup.valid; + return valid ? null : { + thresholds: { + valid: false, + }, + }; + } + + thresholdsFormArray(): UntypedFormArray { + return this.thresholdsFormGroup.get('thresholds') as UntypedFormArray; + } + + trackByThreshold(index: number, thresholdControl: AbstractControl): any { + return thresholdControl; + } + + removeThreshold(index: number) { + (this.thresholdsFormGroup.get('thresholds') as UntypedFormArray).removeAt(index); + } + + addThreshold() { + const threshold = mergeDeep({} as TimeSeriesChartThreshold, + timeSeriesChartThresholdDefaultSettings); + const thresholdsArray = this.thresholdsFormGroup.get('thresholds') as UntypedFormArray; + const thresholdControl = this.fb.control(threshold, [timeSeriesChartThresholdValidator]); + thresholdsArray.push(thresholdControl); + } + + private prepareThresholdsFormArray(thresholds: TimeSeriesChartThreshold[] | undefined): UntypedFormArray { + const thresholdsControls: Array = []; + if (thresholds) { + thresholds.forEach((threshold) => { + thresholdsControls.push(this.fb.control(threshold, [timeSeriesChartThresholdValidator])); + }); + } + return this.fb.array(thresholdsControls); + } + + private checkLatestDataKeys(thresholds: TimeSeriesChartThreshold[]): TimeSeriesChartThreshold[] { + const result: TimeSeriesChartThreshold[] = []; + const latestKeys = this.datasource?.latestDataKeys || []; + for (const threshold of thresholds) { + if (threshold.type === TimeSeriesChartThresholdType.latestKey) { + const found = latestKeys.find(k => this.isThresholdKey(k, threshold)); + if (found) { + result.push(threshold); + } + } else { + result.push(threshold); + } + } + return result; + } + + private updateLatestDataKeys(thresholds: TimeSeriesChartThreshold[]) { + if (this.datasource) { + let latestKeys = this.datasource.latestDataKeys; + if (!latestKeys) { + latestKeys = []; + this.datasource.latestDataKeys = latestKeys; + } + const existingThresholdKeys = latestKeys.filter(k => k.settings?.__thresholdKey === true); + const foundThresholdKeys: DataKey[] = []; + for (const threshold of thresholds) { + if (threshold.type === TimeSeriesChartThresholdType.latestKey) { + const found = existingThresholdKeys.find(k => this.isThresholdKey(k, threshold)); + if (!found) { + const newKey = this.dataKeyCallbacks.generateDataKey(threshold.latestKey, threshold.latestKeyType, + null, true, null); + newKey.settings.__thresholdKey = true; + latestKeys.push(newKey); + } else if (foundThresholdKeys.indexOf(found) === -1) { + foundThresholdKeys.push(found); + } + } + } + const toRemove = existingThresholdKeys.filter(k => foundThresholdKeys.indexOf(k) === -1); + for (const key of toRemove) { + const index = latestKeys.indexOf(key); + if (index > -1) { + latestKeys.splice(index, 1); + } + } + } + } + + private isThresholdKey(d: DataKey, threshold: TimeSeriesChartThreshold): boolean { + return (d.type === DataKeyType.function && d.label === threshold.latestKey) || + (d.type !== DataKeyType.function && d.name === threshold.latestKey && + d.type === threshold.latestKeyType); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html new file mode 100644 index 0000000000..d018b638d0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html @@ -0,0 +1,63 @@ + +
+
{{ 'widgets.time-series-chart.y-axes' | translate }}
+
+
+
widgets.time-series-chart.axis.id
+
widgets.time-series-chart.axis.show
+
widgets.time-series-chart.axis.label
+
widgets.time-series-chart.axis.position
+
widgets.time-series-chart.axis.min
+
widgets.time-series-chart.axis.max
+
widget-config.units-short
+
widget-config.decimals-short
+
+
+
+
+ + +
+ +
+
+
+
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.scss new file mode 100644 index 0000000000..52aa69f59d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.scss @@ -0,0 +1,87 @@ +/** + * 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-y-axes-panel { + .tb-form-table-header-cell { + &.tb-axis-id-header { + width: 60px; + min-width: 60px; + } + + &.tb-label-header { + flex: 1 1 60%; + min-width: 100px; + } + + &.tb-position-header { + flex: 1; + min-width: 60px; + @media #{$mat-gt-md} { + flex: 1 1 40%; + } + } + + &.tb-units-header { + width: 80px; + min-width: 80px; + } + + &.tb-decimals-header { + width: 60px; + min-width: 60px; + } + + &.tb-min-header, &.tb-max-header { + width: 80px; + min-width: 80px; + } + + &.tb-show-header { + width: 40px; + min-width: 40px; + } + + &.tb-actions-header { + width: 80px; + min-width: 80px; + @media #{$mat-gt-md} { + width: 120px; + min-width: 120px; + } + } + + &.tb-label-header, &.tb-units-header, &.tb-decimals-header { + display: none; + @media #{$mat-gt-md} { + display: block; + } + } + + &.tb-min-header, &.tb-max-header { + display: none; + @media #{$mat-gt-xs} { + display: block; + } + } + } + + .tb-form-table-body { + tb-time-series-chart-y-axis-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts new file mode 100644 index 0000000000..9879ffe877 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts @@ -0,0 +1,180 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, forwardRef, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { + defaultTimeSeriesChartYAxisSettings, + getNextTimeSeriesYAxisId, + TimeSeriesChartYAxes, TimeSeriesChartYAxisId, + TimeSeriesChartYAxisSettings, + timeSeriesChartYAxisValid, + timeSeriesChartYAxisValidator +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { mergeDeep } from '@core/utils'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-time-series-chart-y-axes-panel', + templateUrl: './time-series-chart-y-axes-panel.component.html', + styleUrls: ['./time-series-chart-y-axes-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartYAxesPanelComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeSeriesChartYAxesPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + advanced = false; + + @Output() + axisRemoved = new EventEmitter(); + + yAxesFormGroup: UntypedFormGroup; + + get dragEnabled(): boolean { + return this.axesFormArray().controls.length > 1; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit() { + this.yAxesFormGroup = this.fb.group({ + axes: [this.fb.array([]), []] + }); + this.yAxesFormGroup.valueChanges.subscribe( + () => { + let axes: TimeSeriesChartYAxisSettings[] = this.yAxesFormGroup.get('axes').value; + for (let i = 0; i < axes.length; i++) { + axes[i].order = i; + } + if (axes) { + axes = axes.filter(axis => timeSeriesChartYAxisValid(axis)); + } + const yAxes: TimeSeriesChartYAxes = {}; + for (const axis of axes) { + yAxes[axis.id] = axis; + } + this.propagateChange(yAxes); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.yAxesFormGroup.disable({emitEvent: false}); + } else { + this.yAxesFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: TimeSeriesChartYAxes | undefined): void { + const yAxes: TimeSeriesChartYAxes = value || {}; + if (!yAxes.default) { + yAxes.default = mergeDeep({} as TimeSeriesChartYAxisSettings, defaultTimeSeriesChartYAxisSettings, + {id: 'default', order: 0} as TimeSeriesChartYAxisSettings); + } + const yAxisSettingsList = Object.values(yAxes); + yAxisSettingsList.sort((a1, a2) => a1.order - a2.order); + this.yAxesFormGroup.setControl('axes', this.prepareAxesFormArray(yAxisSettingsList), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.yAxesFormGroup.valid; + return valid ? null : { + yAxes: { + valid: false, + }, + }; + } + + axisDrop(event: CdkDragDrop) { + const axesArray = this.yAxesFormGroup.get('axes') as UntypedFormArray; + const axis = axesArray.at(event.previousIndex); + axesArray.removeAt(event.previousIndex); + axesArray.insert(event.currentIndex, axis); + } + + axesFormArray(): UntypedFormArray { + return this.yAxesFormGroup.get('axes') as UntypedFormArray; + } + + trackByAxis(index: number, axisControl: AbstractControl): any { + return axisControl; + } + + removeAxis(index: number) { + const axis = + (this.yAxesFormGroup.get('axes') as UntypedFormArray).at(index).value as TimeSeriesChartYAxisSettings; + (this.yAxesFormGroup.get('axes') as UntypedFormArray).removeAt(index); + this.axisRemoved.emit(axis.id); + } + + addAxis() { + const axis = mergeDeep({} as TimeSeriesChartYAxisSettings, + defaultTimeSeriesChartYAxisSettings); + const axes: TimeSeriesChartYAxisSettings[] = this.yAxesFormGroup.get('axes').value; + axis.id = getNextTimeSeriesYAxisId(axes); + axis.order = axes.length; + const axesArray = this.yAxesFormGroup.get('axes') as UntypedFormArray; + const axisControl = this.fb.control(axis, [timeSeriesChartYAxisValidator]); + axesArray.push(axisControl); + } + + private prepareAxesFormArray(axes: TimeSeriesChartYAxisSettings[]): UntypedFormArray { + const axesControls: Array = []; + axes.forEach((axis) => { + axesControls.push(this.fb.control(axis, [timeSeriesChartYAxisValidator])); + }); + return this.fb.array(axesControls); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html new file mode 100644 index 0000000000..0b1907033f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html @@ -0,0 +1,72 @@ + +
+
+ {{ modelValue.id }} +
+ + + + + + + + {{ timeSeriesAxisPositionTranslations.get(position) | translate }} + + + +
+ + + +
+
+ + + +
+
+ + +
+
+ + + +
+
+ +
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.scss new file mode 100644 index 0000000000..12db0d9228 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.scss @@ -0,0 +1,95 @@ +/** + * 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-form-table-row.tb-axis-row { + + .tb-axis-id-field { + color: rgba(0, 0, 0, 0.76); + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + width: 60px; + min-width: 60px; + } + + .tb-label-field { + flex: 1 1 60%; + min-width: 100px; + } + + .tb-position-field { + flex: 1; + min-width: 60px; + @media #{$mat-gt-md} { + flex: 1 1 40%; + } + } + + .tb-units-field, .tb-decimals-field, .tb-min-field, .tb-max-field { + display: flex; + flex-direction: row; + place-content: center; + align-items: center; + } + + .tb-units-field { + width: 80px; + min-width: 80px; + } + + .tb-decimals-field { + width: 60px; + min-width: 60px; + } + + .tb-min-field, .tb-max-field { + width: 80px; + min-width: 80px; + } + + .tb-show-field { + width: 40px; + min-width: 40px; + } + + .tb-label-field { + display: none; + @media #{$mat-gt-md} { + display: block; + } + } + + .tb-units-field, .tb-decimals-field { + display: none; + @media #{$mat-gt-md} { + display: flex; + } + } + + .tb-min-field, .tb-max-field { + display: none; + @media #{$mat-gt-xs} { + display: flex; + } + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts new file mode 100644 index 0000000000..a4824fd51e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts @@ -0,0 +1,201 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { + AxisPosition, + timeSeriesAxisPositionTranslations, + TimeSeriesChartYAxisSettings +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { + TimeSeriesChartYAxisSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-time-series-chart-y-axis-row', + templateUrl: './time-series-chart-y-axis-row.component.html', + styleUrls: ['./time-series-chart-y-axis-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartYAxisRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, OnInit { + + axisPositions = [AxisPosition.left, AxisPosition.right]; + + timeSeriesAxisPositionTranslations = timeSeriesAxisPositionTranslations; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + advanced = false; + + @Output() + axisRemoved = new EventEmitter(); + + axisFormGroup: UntypedFormGroup; + + modelValue: TimeSeriesChartYAxisSettings; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) { + } + + ngOnInit() { + this.axisFormGroup = this.fb.group({ + label: [null, []], + position: [null, []], + units: [null, []], + decimals: [null, []], + min: [null, []], + max: [null, []], + show: [null, []] + }); + this.axisFormGroup.valueChanges.subscribe( + () => this.updateModel() + ); + this.axisFormGroup.get('show').valueChanges.subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.axisFormGroup.disable({emitEvent: false}); + } else { + this.axisFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: TimeSeriesChartYAxisSettings): void { + this.modelValue = value; + this.axisFormGroup.patchValue( + { + label: value.label, + position: value.position, + units: value.units, + decimals: value.decimals, + min: value.min, + max: value.max, + show: value.show, + }, {emitEvent: false} + ); + this.updateValidators(); + this.cd.markForCheck(); + } + + editAxis($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 = { + yAxisSettings: deepClone(this.modelValue), + advanced: this.advanced + }; + const yAxisSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, TimeSeriesChartYAxisSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + yAxisSettingsPanelPopover.tbComponentRef.instance.popover = yAxisSettingsPanelPopover; + yAxisSettingsPanelPopover.tbComponentRef.instance.yAxisSettingsApplied.subscribe((yAxisSettings) => { + yAxisSettingsPanelPopover.hide(); + this.modelValue = {...this.modelValue, ...yAxisSettings}; + this.axisFormGroup.patchValue( + { + label: this.modelValue.label, + position: this.modelValue.position, + units: this.modelValue.units, + decimals: this.modelValue.decimals, + min: this.modelValue.min, + max: this.modelValue.max, + show: this.modelValue.show + }, + {emitEvent: false}); + this.updateValidators(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateValidators() { + const show: boolean = this.axisFormGroup.get('show').value; + if (show) { + this.axisFormGroup.get('label').enable({emitEvent: false}); + this.axisFormGroup.get('position').enable({emitEvent: false}); + this.axisFormGroup.get('units').enable({emitEvent: false}); + this.axisFormGroup.get('decimals').enable({emitEvent: false}); + } else { + this.axisFormGroup.get('label').disable({emitEvent: false}); + this.axisFormGroup.get('position').disable({emitEvent: false}); + this.axisFormGroup.get('units').disable({emitEvent: false}); + this.axisFormGroup.get('decimals').disable({emitEvent: false}); + } + } + + private updateModel() { + const value = this.axisFormGroup.value; + this.modelValue.label = value.label; + this.modelValue.position = value.position; + this.modelValue.units = value.units; + this.modelValue.decimals = value.decimals; + this.modelValue.min = value.min; + this.modelValue.max = value.max; + this.modelValue.show = value.show; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.html new file mode 100644 index 0000000000..759074b4be --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.html @@ -0,0 +1,43 @@ + +
+
{{ 'widgets.time-series-chart.axis.y-axis-settings' | translate }}
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.scss new file mode 100644 index 0000000000..095c38b7f7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.scss @@ -0,0 +1,49 @@ +/** + * 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-y-axis-settings-panel { + width: 530px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-y-axis-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-y-axis-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-y-axis-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.ts new file mode 100644 index 0000000000..8046aafd29 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-settings-panel.component.ts @@ -0,0 +1,66 @@ +/// +/// 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 { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { TimeSeriesChartYAxisSettings } from '@home/components/widget/lib/chart/time-series-chart.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-time-series-chart-y-axis-settings-panel', + templateUrl: './time-series-chart-y-axis-settings-panel.component.html', + providers: [], + styleUrls: ['./time-series-chart-y-axis-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class TimeSeriesChartYAxisSettingsPanelComponent implements OnInit { + + @Input() + yAxisSettings: TimeSeriesChartYAxisSettings; + + @Input() + @coerceBoolean() + advanced = false; + + @Input() + popover: TbPopoverComponent; + + @Output() + yAxisSettingsApplied = new EventEmitter(); + + yAxisSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit(): void { + this.yAxisSettingsFormGroup = this.fb.group( + { + yAxis: [this.yAxisSettings, []] + } + ); + } + + cancel() { + this.popover?.hide(); + } + + applyYAxisSettings() { + const yAxisSettings = this.yAxisSettingsFormGroup.get('yAxis').getRawValue(); + this.yAxisSettingsApplied.emit(yAxisSettings); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.html new file mode 100644 index 0000000000..239c6ea18c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.html @@ -0,0 +1,75 @@ + +
+
+
widgets.time-series-chart.no-aggregation-bar-width-strategy
+ + + {{ timeSeriesChartNoAggregationBarWidthStrategyTranslations.get(strategy) | translate }} + + +
+ + + + + + + + +
+ +
+
{{ label }}
+
+ + + + {{ (formGroup.get('relative').value ? + 'widgets.time-series-chart.bar-width-relative' : + 'widgets.time-series-chart.bar-width-absolute') | translate }} + + + {{ 'widgets.time-series-chart.bar-width-relative' | translate }} + + + {{ 'widgets.time-series-chart.bar-width-absolute' | translate }} + + + + + + % + + + + ms + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.ts new file mode 100644 index 0000000000..36d8cb917a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-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, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + TimeSeriesChartNoAggregationBarWidthSettings, + timeSeriesChartNoAggregationBarWidthStrategies, + TimeSeriesChartNoAggregationBarWidthStrategy, + timeSeriesChartNoAggregationBarWidthStrategyTranslations +} from '@home/components/widget/lib/chart/time-series-chart.models'; +import { merge } from 'rxjs'; + +@Component({ + selector: 'tb-time-series-no-aggregation-bar-width-settings', + templateUrl: './time-series-no-aggregation-bar-width-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesNoAggregationBarWidthSettingsComponent), + multi: true + } + ] +}) +export class TimeSeriesNoAggregationBarWidthSettingsComponent implements OnInit, ControlValueAccessor { + + TimeSeriesChartNoAggregationBarWidthStrategy = TimeSeriesChartNoAggregationBarWidthStrategy; + + timeSeriesChartNoAggregationBarWidthStrategies = timeSeriesChartNoAggregationBarWidthStrategies; + + timeSeriesChartNoAggregationBarWidthStrategyTranslations = timeSeriesChartNoAggregationBarWidthStrategyTranslations; + + @Input() + disabled: boolean; + + private modelValue: TimeSeriesChartNoAggregationBarWidthSettings; + + private propagateChange = null; + + public barWidthSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder) { + } + + ngOnInit(): void { + this.barWidthSettingsFormGroup = this.fb.group({ + strategy: [null, []], + groupWidth: this.fb.group({ + relative: [null, []], + relativeWidth: [null, [Validators.required, Validators.min(0.1), Validators.max(100)]], + absoluteWidth: [null, [Validators.required, Validators.min(100)]] + }), + barWidth: this.fb.group({ + relative: [null, []], + relativeWidth: [null, [Validators.required, Validators.min(0.1), Validators.max(100)]], + absoluteWidth: [null, [Validators.required, Validators.min(100)]] + }) + }); + this.barWidthSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + merge(this.barWidthSettingsFormGroup.get('strategy').valueChanges, + this.barWidthSettingsFormGroup.get('groupWidth.relative').valueChanges, + this.barWidthSettingsFormGroup.get('barWidth.relative').valueChanges) + .subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.barWidthSettingsFormGroup.disable({emitEvent: false}); + } else { + this.barWidthSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: TimeSeriesChartNoAggregationBarWidthSettings): void { + this.modelValue = value; + this.barWidthSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateValidators() { + const strategy: TimeSeriesChartNoAggregationBarWidthStrategy = + this.barWidthSettingsFormGroup.get('strategy').value; + const groupWidthRelative: boolean = this.barWidthSettingsFormGroup.get('groupWidth.relative').value; + const barWidthRelative: boolean = this.barWidthSettingsFormGroup.get('barWidth.relative').value; + if (strategy === TimeSeriesChartNoAggregationBarWidthStrategy.group) { + this.barWidthSettingsFormGroup.get('groupWidth').enable({emitEvent: false}); + this.barWidthSettingsFormGroup.get('barWidth').disable({emitEvent: false}); + if (groupWidthRelative) { + this.barWidthSettingsFormGroup.get('groupWidth').get('relativeWidth').enable({emitEvent: false}); + this.barWidthSettingsFormGroup.get('groupWidth').get('absoluteWidth').disable({emitEvent: false}); + } else { + this.barWidthSettingsFormGroup.get('groupWidth').get('relativeWidth').disable({emitEvent: false}); + this.barWidthSettingsFormGroup.get('groupWidth').get('absoluteWidth').enable({emitEvent: false}); + } + } else if (strategy === TimeSeriesChartNoAggregationBarWidthStrategy.separate) { + this.barWidthSettingsFormGroup.get('groupWidth').disable({emitEvent: false}); + this.barWidthSettingsFormGroup.get('barWidth').enable({emitEvent: false}); + if (barWidthRelative) { + this.barWidthSettingsFormGroup.get('barWidth').get('relativeWidth').enable({emitEvent: false}); + this.barWidthSettingsFormGroup.get('barWidth').get('absoluteWidth').disable({emitEvent: false}); + } else { + this.barWidthSettingsFormGroup.get('barWidth').get('relativeWidth').disable({emitEvent: false}); + this.barWidthSettingsFormGroup.get('barWidth').get('absoluteWidth').enable({emitEvent: false}); + } + } + } + + private updateModel() { + this.modelValue = this.barWidthSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.html index 8db7f074de..68030f89bf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.html @@ -1,6 +1,6 @@ +
+ + + + warning + + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.ts new file mode 100644 index 0000000000..099778e924 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.ts @@ -0,0 +1,138 @@ +/// +/// 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, HostBinding, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { cssUnit, resolveCssSize } from '@shared/models/widget-settings.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-css-size-input', + templateUrl: './css-size-input.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CssSizeInputComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CssSizeInputComponent), + multi: true, + } + ] +}) +export class CssSizeInputComponent implements OnInit, ControlValueAccessor, Validator { + + @HostBinding('style.width') + get hostWidth(): string { + return this.flex ? '100%' : null; + } + + @HostBinding('style.flex') + get hostFlex(): string { + return this.flex ? '1' : null; + } + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + required = false; + + @Input() + requiredText: string; + + @Input() + @coerceBoolean() + allowEmptyUnit = false; + + @Input() + @coerceBoolean() + flex = false; + + cssSizeFormGroup: UntypedFormGroup; + + modelValue: string; + + private propagateChange = null; + + constructor(private fb: UntypedFormBuilder) {} + + ngOnInit(): void { + this.cssSizeFormGroup = this.fb.group({ + size: [null, this.required ? [Validators.required, Validators.min(0)] : [Validators.min(0)]], + unit: [null, []] + }); + this.cssSizeFormGroup.valueChanges.subscribe((value: {size: number; unit: cssUnit}) => { + this.updateModel(value); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.cssSizeFormGroup.disable({emitEvent: false}); + } else { + this.cssSizeFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string): void { + this.modelValue = value; + const size = resolveCssSize(value); + this.cssSizeFormGroup.patchValue({ + size: size[0], + unit: size[1] + }, {emitEvent: false}); + } + + validate(_c: UntypedFormControl) { + return this.cssSizeFormGroup.valid ? null : { + cssSize: { + valid: false, + } + }; + } + + private updateModel(value: {size: number; unit: cssUnit}): void { + const result: string = isDefinedAndNotNull(value?.size) && isDefinedAndNotNull(value?.unit) + ? value.size + value.unit : ''; + if (this.modelValue !== result) { + this.modelValue = result; + this.propagateChange(this.modelValue); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.html index 1c00141b1d..095310606f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.html @@ -1,6 +1,6 @@ - + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.ts index 61a6bc4865..588871b7b9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.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. @@ -40,6 +40,9 @@ export class CssUnitSelectComponent implements OnInit, ControlValueAccessor { @coerceBoolean() allowEmpty = false; + @Input() + width = '100%'; + cssUnitsList = cssUnits; cssUnitFormControl: UntypedFormControl; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.html new file mode 100644 index 0000000000..94831b530f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.html @@ -0,0 +1,162 @@ + + + + +
+
+
+ + notifications + + + timeline + +
+
+ + + +
+
+ + +
+
+ +
+ + warning + + + + + + notifications + + + timeline + + + + + +
+
+ entity.no-keys-found +
+ + + {{ translate.get('entity.no-key-matching', + {key: truncate.transform(keySearchText, true, 6, '...')}) | async }} + + + entity.create-new-key + + + {{'entity.create-new-key' | translate }} + notifications + + + timeline + + +
+
+
+
+ + + f() + + + + + + + + {{ modelValue?.aggregationType }}({{ modelValue?.name }}) + + + {{modelValue?.name}} + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss new file mode 100644 index 0000000000..6d443d0057 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss @@ -0,0 +1,46 @@ +/** + * 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-data-key-input { + .mat-mdc-form-field.tb-inline-field.tb-key-field { + width: 100%; + .mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) { + padding-left: 8px; + padding-right: 0; + + .mat-mdc-form-field-infix { + padding-top: 0; + padding-bottom: 6px; + + .mdc-evolution-chip-set .mdc-evolution-chip { + margin: 0; + } + + input.mat-mdc-chip-input { + height: 32px; + margin-left: 0; + } + } + } + + .mat-mdc-chip.mat-mdc-standard-chip.tb-datakey-chip { + .tb-attribute-chip { + .tb-chip-labels { + background: transparent; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts new file mode 100644 index 0000000000..d26f259185 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts @@ -0,0 +1,401 @@ +/// +/// 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, + EventEmitter, + forwardRef, + HostBinding, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + Validators +} from '@angular/forms'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; +import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { MatChipGrid, MatChipInputEvent } from '@angular/material/chips'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { Observable, of } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { UtilsService } from '@core/services/utils.service'; +import { alarmFields } from '@shared/models/alarm.models'; +import { filter, map, mergeMap, publishReplay, refCount, share, tap } from 'rxjs/operators'; +import { AggregationType } from '@shared/models/time/time.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { IAliasController } from '@core/api/widget-api.models'; + +@Component({ + selector: 'tb-data-key-input', + templateUrl: './data-key-input.component.html', + styleUrls: ['./data-key-input.component.scss', '../../../config/data-keys.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataKeyInputComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class DataKeyInputComponent implements ControlValueAccessor, OnInit, OnChanges { + + @HostBinding('class') + hostClass = 'tb-data-key-input'; + + DataKeyType = DataKeyType; + + separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON]; + + @ViewChild('keyInput') keyInput: ElementRef; + @ViewChild('keyAutocomplete') matAutocomplete: MatAutocomplete; + @ViewChild(MatAutocompleteTrigger) autocomplete: MatAutocompleteTrigger; + @ViewChild('chipList') chipList: MatChipGrid; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + required = false; + + @Input() + requiredText: string; + + @Input() + @coerceBoolean() + isLatestDataKeys = false; + + @Input() + @coerceBoolean() + editable = true; + + @Input() + datasourceType: DatasourceType; + + @Input() + entityAliasId: string; + + @Input() + entityAlias: string; + + @Input() + deviceId: string; + + @Input() + widgetType: widgetType; + + @Input() + callbacks: DataKeysCallbacks; + + @Input() + aliasController: IAliasController; + + @Input() + dataKeyType: DataKeyType; + + @Input() + dataKeyTypes: DataKeyType[]; + + @Input() + generateKey: (key: DataKey) => DataKey = (key) => key; + + @Output() + keyEdit = new EventEmitter(); + + keysFormControl: UntypedFormControl; + + keyFormControl: UntypedFormControl; + + modelValue: DataKey; + + filteredKeys: Observable>; + + keySearchText = ''; + + alarmKeys: Array; + functionTypeKeys: Array; + + allowedDataKeyTypes: DataKeyType[] = []; + + private latestKeySearchTextResult: Array = null; + private keyFetchObservable$: Observable> = null; + + get isEntityDatasource(): boolean { + return [DatasourceType.device, DatasourceType.entity].includes(this.datasourceType); + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, + private cd: ChangeDetectorRef, + public translate: TranslateService, + public truncate: TruncatePipe, + private utils: UtilsService) { + } + + ngOnInit() { + this.alarmKeys = []; + for (const name of Object.keys(alarmFields)) { + this.alarmKeys.push({ + name, + type: DataKeyType.alarm + }); + } + this.functionTypeKeys = []; + for (const type of this.utils.getPredefinedFunctionsList()) { + this.functionTypeKeys.push({ + name: type, + type: DataKeyType.function + }); + } + this.keyFormControl = this.fb.control(''); + this.keysFormControl = this.fb.control([], this.required ? [Validators.required] : []); + this.filteredKeys = this.keyFormControl.valueChanges + .pipe( + tap((value: string | DataKey) => { + if (value && typeof value !== 'string') { + this.addKeyFromChipValue(value); + } else if (value === null) { + this.clearKeyChip(this.keyInput.nativeElement.value); + } + }), + filter((value) => typeof value === 'string'), + map((value) => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchKeys(name) ), + share() + ); + this.updateAllowedDataKeys(); + } + + private updateAllowedDataKeys() { + this.allowedDataKeyTypes.length = 0; + if (this.dataKeyTypes?.length) { + this.allowedDataKeyTypes = this.allowedDataKeyTypes.concat(this.dataKeyTypes); + } else { + this.allowedDataKeyTypes = [DataKeyType.timeseries]; + if (this.isLatestDataKeys || this.widgetType === widgetType.latest || this.widgetType === widgetType.alarm) { + this.allowedDataKeyTypes.push(DataKeyType.attribute); + this.allowedDataKeyTypes.push(DataKeyType.entityField); + if (this.widgetType === widgetType.alarm) { + this.allowedDataKeyTypes.push(DataKeyType.alarm); + } + } + } + } + + private reset() { + if (this.keyInput) { + this.keyInput.nativeElement.value = ''; + } + this.keyFormControl.patchValue('', {emitEvent: false}); + this.latestKeySearchTextResult = null; + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['deviceId', 'entityAliasId', 'entityAlias', 'isLatestDataKeys'].includes(propName)) { + this.clearKeySearchCache(); + if (propName === 'isLatestDataKeys') { + this.updateAllowedDataKeys(); + if (!this.isLatestDataKeys) { + if (this.widgetType === widgetType.timeseries && + this.modelValue?.type && + this.modelValue.type !== DataKeyType.timeseries) { + setTimeout(() => { + this.modelValue = null; + this.updateModel(); + this.clearKeyChip('', false); + }, 1); + } + } + } + } else if (['datasourceType'].includes(propName)) { + if ([DatasourceType.device, DatasourceType.entity].includes(change.previousValue) && + [DatasourceType.device, DatasourceType.entity].includes(change.currentValue)) { + this.clearKeySearchCache(); + } else { + this.clearKeySearchCache(); + setTimeout(() => { + this.reset(); + }, 1); + } + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.keysFormControl.disable({emitEvent: false}); + } else { + this.keysFormControl.enable({emitEvent: false}); + } + } + + writeValue(value: DataKey): void { + this.modelValue = (value?.name && value?.type) ? value : null; + this.keysFormControl.patchValue(this.modelValue ? [this.modelValue] : [], {emitEvent: false}); + this.cd.markForCheck(); + } + + dataKeyHasAggregation(): boolean { + return this.widgetType === widgetType.latest && this.modelValue?.type === DataKeyType.timeseries + && this.modelValue?.aggregationType && this.modelValue?.aggregationType !== AggregationType.NONE; + } + + dataKeyHasPostprocessing(): boolean { + return !!this.modelValue?.postFuncBody; + } + + displayKeyFn(key?: DataKey): string | undefined { + return key ? key.name : undefined; + } + + createKey(name: string, dataKeyType: DataKeyType = this.dataKeyType) { + this.addKeyFromChipValue({name: name ? name.trim() : '', type: dataKeyType}); + } + + addKey(event: MatChipInputEvent): void { + const value = event.value; + if ((value || '').trim() && this.dataKeyType) { + this.addKeyFromChipValue({name: value.trim(), type: this.dataKeyType}); + } else { + this.clearKeyChip(); + } + } + + editKey() { + this.keyEdit.emit(this.modelValue); + } + + removeKey() { + this.modelValue = null; + this.updateModel(); + this.clearKeyChip(); + } + + textIsNotEmpty(text: string): boolean { + return text && text.length > 0; + } + + clearKeyChip(value: string = '', focus = true) { + this.autocomplete.closePanel(); + this.keyInput.nativeElement.value = value; + this.keyFormControl.patchValue(value, {emitEvent: focus}); + if (focus) { + setTimeout(() => { + this.keyInput.nativeElement.blur(); + this.keyInput.nativeElement.focus(); + }, 0); + } + } + + onKeyInputFocus() { + if (!this.modelValue?.type) { + this.keyFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true}); + } + } + + private fetchKeys(searchText?: string): Observable> { + if (this.keySearchText !== searchText || this.latestKeySearchTextResult === null) { + this.keySearchText = searchText; + const dataKeyFilter = this.createDataKeyFilter(this.keySearchText); + return this.getKeys().pipe( + map(name => name.filter(dataKeyFilter)), + tap(res => this.latestKeySearchTextResult = res) + ); + } + return of(this.latestKeySearchTextResult); + } + + private getKeys(): Observable> { + if (this.keyFetchObservable$ === null) { + let fetchObservable: Observable>; + if (this.datasourceType === DatasourceType.function) { + const targetKeysList = this.widgetType === widgetType.alarm ? this.alarmKeys : this.functionTypeKeys; + fetchObservable = of(targetKeysList); + } else if (this.datasourceType === DatasourceType.entity && (this.entityAliasId || this.entityAlias) || + this.datasourceType === DatasourceType.device && this.deviceId) { + if (this.datasourceType === DatasourceType.device) { + fetchObservable = this.callbacks.fetchEntityKeysForDevice(this.deviceId, this.allowedDataKeyTypes); + } else { + let entityAliasId = this.entityAliasId; + if (!entityAliasId && this.entityAlias && this.aliasController) { + entityAliasId = this.aliasController.getEntityAliasId(this.entityAlias); + } + fetchObservable = entityAliasId ? this.callbacks.fetchEntityKeys(entityAliasId, this.allowedDataKeyTypes) : of([]); + } + } else { + fetchObservable = of([]); + } + this.keyFetchObservable$ = fetchObservable.pipe( + publishReplay(1), + refCount() + ); + } + return this.keyFetchObservable$; + } + + private createDataKeyFilter(query: string): (key: DataKey) => boolean { + const lowercaseQuery = query.toLowerCase(); + return key => key.name.toLowerCase().startsWith(lowercaseQuery); + } + + private addKeyFromChipValue(chip: DataKey) { + this.modelValue = this.generateKey(chip); + this.updateModel(); + this.clearKeyChip('', false); + } + + private clearKeySearchCache() { + this.keySearchText = ''; + this.keyFetchObservable$ = null; + this.latestKeySearchTextResult = null; + } + + private updateModel() { + this.keysFormControl.patchValue(this.modelValue ? [this.modelValue] : [], {emitEvent: false}); + this.propagateChange(this.modelValue); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.html index 7310d0c403..93d4ec68c6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.html @@ -1,6 +1,6 @@ + + + + warning + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.scss new file mode 100644 index 0000000000..baccbf2ab7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.scss @@ -0,0 +1,20 @@ +/** + * 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-entity-alias-input { + .mat-mdc-form-field.tb-inline-field { + width: 100%; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts new file mode 100644 index 0000000000..dd6d19f14e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts @@ -0,0 +1,156 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + HostBinding, + Input, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + Validators +} from '@angular/forms'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { IAliasController } from '@core/api/widget-api.models'; +import { map, mergeMap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { TimeSeriesChartThresholdType } from '@home/components/widget/lib/chart/time-series-chart.models'; + +@Component({ + selector: 'tb-entity-alias-input', + templateUrl: './entity-alias-input.component.html', + styleUrls: ['./entity-alias-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAliasInputComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class EntityAliasInputComponent implements ControlValueAccessor, OnInit { + + @HostBinding('class') + hostClass = 'tb-entity-alias-input'; + + @ViewChild('entityAliasInput') entityAliasInput: ElementRef; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + required = false; + + @Input() + aliasController: IAliasController; + + entityAliasFormControl: UntypedFormControl; + + filteredEntityAliases: Observable>; + aliasSearchText = ''; + + private entityAliasList: Array = []; + private entityAliasDirty = false; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef) { + } + + ngOnInit() { + this.entityAliasFormControl = this.fb.control(null, this.required ? [Validators.required] : []); + this.entityAliasFormControl.valueChanges.subscribe( + () => this.updateModel() + ); + + this.filteredEntityAliases = this.entityAliasFormControl.valueChanges + .pipe( + map(value => value ? value : ''), + mergeMap(name => this.fetchEntityAliases(name) ) + ); + + if (this.aliasController) { + const entityAliases = this.aliasController.getEntityAliases(); + for (const aliasId of Object.keys(entityAliases)) { + this.entityAliasList.push(entityAliases[aliasId].alias); + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.entityAliasFormControl.disable({emitEvent: false}); + } else { + this.entityAliasFormControl.enable({emitEvent: false}); + } + } + + writeValue(value: string): void { + this.entityAliasFormControl.patchValue(value, {emitEvent: false}); + this.entityAliasDirty = true; + } + + onEntityAliasFocus() { + if (this.entityAliasDirty) { + this.entityAliasFormControl.updateValueAndValidity({onlySelf: true}); + this.entityAliasDirty = false; + } + } + + clearEntityAlias() { + this.entityAliasFormControl.patchValue(null, {emitEvent: true}); + setTimeout(() => { + this.entityAliasInput.nativeElement.blur(); + this.entityAliasInput.nativeElement.focus(); + }, 0); + } + + private fetchEntityAliases(searchText?: string): Observable> { + this.aliasSearchText = searchText; + let result = this.entityAliasList; + if (searchText && searchText.length) { + result = this.entityAliasList.filter((entityAlias) => entityAlias.toLowerCase().includes(searchText.toLowerCase())); + } + return of(result); + } + + private updateModel() { + const value = this.entityAliasFormControl.value; + this.propagateChange(value); + } + + protected readonly TimeSeriesChartThresholdType = TimeSeriesChartThresholdType; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/font-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/font-settings-panel.component.html index c5687be400..d171833001 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/font-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/font-settings-panel.component.html @@ -1,6 +1,6 @@ -
+
{{ 'legend.direction' | translate }}
@@ -31,8 +31,9 @@ + [disabled]="!hideDirection && + legendConfigForm.get('direction').value === legendDirection.row && + (pos === legendPosition.left || pos === legendPosition.right)"> {{ legendPositionTranslations.get(legendPosition[pos]) | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.ts index 15b2bbe316..8b065247f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.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. @@ -25,6 +25,7 @@ import { legendPositionTranslationMap } from '@shared/models/widget.models'; import { Subscription } from 'rxjs'; +import { coerceBoolean } from '@shared/decorators/coercion'; // @dynamic @Component({ @@ -43,6 +44,10 @@ export class LegendConfigComponent implements OnInit, OnDestroy, ControlValueAcc @Input() disabled: boolean; + @Input() + @coerceBoolean() + hideDirection = false; + legendConfigForm: UntypedFormGroup; legendDirection = LegendDirection; legendDirections = Object.keys(LegendDirection); @@ -60,15 +65,17 @@ export class LegendConfigComponent implements OnInit, OnDestroy, ControlValueAcc ngOnInit(): void { this.legendConfigForm = this.fb.group({ - direction: [null, []], position: [null, []], showValues: [[], []], sortDataKeys: [null, []] }); - this.legendSettingsFormDirectionChanges$ = this.legendConfigForm.get('direction').valueChanges + if (!this.hideDirection) { + this.legendConfigForm.addControl('direction', this.fb.control([null, []])); + this.legendSettingsFormDirectionChanges$ = this.legendConfigForm.get('direction').valueChanges .subscribe((direction: LegendDirection) => { this.onDirectionChanged(direction); }); + } this.legendSettingsFormChanges$ = this.legendConfigForm.valueChanges.subscribe( () => this.legendConfigUpdated() ); @@ -114,23 +121,30 @@ export class LegendConfigComponent implements OnInit, OnDestroy, ControlValueAcc writeValue(legendConfig: LegendConfig): void { if (legendConfig) { - this.legendConfigForm.patchValue({ - direction: legendConfig.direction, + const value: any = { position: legendConfig.position, showValues: this.getShowValues(legendConfig), sortDataKeys: isDefined(legendConfig.sortDataKeys) ? legendConfig.sortDataKeys : false - }, {emitEvent: false}); + }; + if (!this.hideDirection) { + value.direction = legendConfig.direction; + } + this.legendConfigForm.patchValue(value, {emitEvent: false}); + } + if (!this.hideDirection) { + this.onDirectionChanged(legendConfig?.direction); } - this.onDirectionChanged(legendConfig.direction); } private legendConfigUpdated() { const configValue = this.legendConfigForm.value; const legendConfig: Partial = { - direction: configValue.direction, position: configValue.position, sortDataKeys: configValue.sortDataKeys }; + if (!this.hideDirection) { + legendConfig.direction = configValue.direction; + } this.setShowValues(configValue.showValues, legendConfig); this.propagateChange(legendConfig); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html index 726aed776e..cb5d62040d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html @@ -1,6 +1,6 @@ - - {{ (keyType === dataKeyType.attribute + + {{ (keyType === dataKeyType.attribute ? attributeLabel : timeseriesLabel) | translate }} close + + warning + @@ -38,4 +52,7 @@ + + {{ requiredText | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts index 22f8bf8e75..9c0691faed 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.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. @@ -25,15 +25,17 @@ import { import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { TranslateService } from '@ngx-translate/core'; -import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { Observable, of } from 'rxjs'; import { IAliasController } from '@core/api/widget-api.models'; import { catchError, map, mergeMap, publishReplay, refCount, tap } from 'rxjs/operators'; -import { DataKey } from '@shared/models/widget.models'; +import { DataKey, TargetDevice, TargetDeviceType, targetDeviceValid } from '@shared/models/widget.models'; import { EntityService } from '@core/http/entity.service'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { EntityType } from '@shared/models/entity-type.models'; +import { EntityFilter, singleEntityFilterFromDeviceId } from '@shared/models/query/query.models'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-device-key-autocomplete', @@ -58,25 +60,30 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI aliasController: IAliasController; @Input() - targetDeviceAliasId: string; + targetDevice: TargetDevice; @Input() keyType: DataKeyType; + @Input() + attributeScope: AttributeScope; + @Input() attributeLabel = 'widgets.rpc.attribute-value-key'; @Input() timeseriesLabel = 'widgets.rpc.timeseries-value-key'; - private requiredValue: boolean; - get required(): boolean { - return this.requiredValue; - } @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } + requiredText: string; + + @Input() + @coerceBoolean() + required: boolean; + + @Input() + @coerceBoolean() + inlineField: boolean; dataKeyType = DataKeyType; @@ -93,7 +100,6 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI private keysFetchObservable$: Observable> = null; constructor(protected store: Store, - private translate: TranslateService, private entityService: EntityService, private fb: UntypedFormBuilder) { super(store); @@ -118,7 +124,7 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI for (const propName of Object.keys(changes)) { const change = changes[propName]; if (!change.firstChange && change.currentValue !== change.previousValue) { - if (propName === 'targetDeviceAliasId' || propName === 'keyType') { + if (propName === 'targetDevice' || propName === 'keyType' || propName === 'attributeScope') { this.clearKeysCache(); } } @@ -186,9 +192,9 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI private getKeys() { if (this.keysFetchObservable$ === null) { let fetchObservable: Observable>; - if (this.targetDeviceAliasId) { + if (targetDeviceValid(this.targetDevice)) { const dataKeyTypes = [this.keyType]; - fetchObservable = this.fetchEntityKeys(this.targetDeviceAliasId, dataKeyTypes); + fetchObservable = this.fetchEntityKeys(this.targetDevice, dataKeyTypes); } else { fetchObservable = of([]); } @@ -201,17 +207,25 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI return this.keysFetchObservable$; } - private fetchEntityKeys(entityAliasId: string, dataKeyTypes: Array): Observable> { - return this.aliasController.getAliasInfo(entityAliasId).pipe( - mergeMap((aliasInfo) => { - return this.entityService.getEntityKeysByEntityFilter( - aliasInfo.entityFilter, + private fetchEntityKeys(targetDevice: TargetDevice, dataKeyTypes: Array): Observable> { + let entityFilter$: Observable; + if (targetDevice.type === TargetDeviceType.device) { + entityFilter$ = of(singleEntityFilterFromDeviceId(targetDevice.deviceId)); + } else { + entityFilter$ = this.aliasController.getAliasInfo(targetDevice.entityAliasId).pipe( + map(aliasInfo => aliasInfo.entityFilter) + ); + } + return entityFilter$.pipe( + mergeMap((entityFilter) => + this.entityService.getEntityKeysByEntityFilterAndScope( + entityFilter, dataKeyTypes, [EntityType.DEVICE], + this.attributeScope, {ignoreLoading: true, ignoreErrors: true} ).pipe( catchError(() => of([])) - ); - }), + )), catchError(() => of([] as Array)) ); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html index 2a1bc2d090..43e70f8810 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html @@ -1,6 +1,6 @@ + +
+
widgets.single-switch.behavior
+
+
widgets.rpc-state.initial-state
+ +
+
+
widgets.rpc-state.turn-on
+ +
+
+
widgets.rpc-state.turn-off
+ +
+
+
widgets.rpc-state.disabled-state
+ +
+
+
+
widget-config.card-style
+ + + {{ singleSwitchLayoutTranslationMap.get(layout) | translate }} + + +
+ + {{ 'widgets.single-switch.auto-scale' | translate }} + +
+
+ + {{ 'widgets.single-switch.label' | translate }} + +
+ + + + + + + +
+
+
+ + {{ 'widgets.single-switch.icon' | translate }} + +
+ + + + + + + + +
+
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
+
widgets.single-switch.switch
+
+
{{ 'widgets.single-switch.switch-color' | translate }}
+
+
+
widgets.single-switch.on
+ + +
+ +
+
widgets.single-switch.off
+ + +
+ +
+
widgets.single-switch.disabled
+ + +
+
+
+
+
{{ 'widgets.single-switch.tumbler-color' | translate }}
+
+
+
widgets.single-switch.on
+ + +
+ +
+
widgets.single-switch.off
+ + +
+ +
+
widgets.single-switch.disabled
+ + +
+
+
+
+ + {{ 'widgets.single-switch.on-label' | translate }} + +
+ + + + + + + +
+
+
+ + {{ 'widgets.single-switch.off-label' | translate }} + +
+ + + + + + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts new file mode 100644 index 0000000000..a1a3e951f2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts @@ -0,0 +1,161 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + singleSwitchDefaultSettings, + singleSwitchLayoutImages, + singleSwitchLayouts, + singleSwitchLayoutTranslations +} from '@home/components/widget/lib/rpc/single-switch-widget.models'; +import { ValueType } from '@shared/models/constants'; + +@Component({ + selector: 'tb-single-switch-widget-settings', + templateUrl: './single-switch-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class SingleSwitchWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + return this.widgetConfig?.config?.targetDevice; + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + + singleSwitchLayouts = singleSwitchLayouts; + + singleSwitchLayoutTranslationMap = singleSwitchLayoutTranslations; + singleSwitchLayoutImageMap = singleSwitchLayoutImages; + + valueType = ValueType; + + singleSwitchWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.singleSwitchWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...singleSwitchDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.singleSwitchWidgetSettingsForm = this.fb.group({ + initialState: [settings.initialState, []], + onUpdateState: [settings.onUpdateState, []], + offUpdateState: [settings.offUpdateState, []], + disabledState: [settings.disabledState, []], + layout: [settings.layout, []], + autoScale: [settings.autoScale, []], + + showLabel: [settings.showLabel, []], + label: [settings.label, []], + labelFont: [settings.labelFont, []], + labelColor: [settings.labelColor, []], + + showIcon: [settings.showIcon, []], + iconSize: [settings.iconSize, [Validators.min(0)]], + iconSizeUnit: [settings.iconSizeUnit, []], + icon: [settings.icon, []], + iconColor: [settings.iconColor, []], + + switchColorOn: [settings.switchColorOn, []], + switchColorOff: [settings.switchColorOff, []], + switchColorDisabled: [settings.switchColorDisabled, []], + + tumblerColorOn: [settings.tumblerColorOn, []], + tumblerColorOff: [settings.tumblerColorOff, []], + tumblerColorDisabled: [settings.tumblerColorDisabled, []], + + showOnLabel: [settings.showOnLabel, []], + onLabel: [settings.onLabel, []], + onLabelFont: [settings.onLabelFont, []], + onLabelColor: [settings.onLabelColor, []], + + showOffLabel: [settings.showOffLabel, []], + offLabel: [settings.offLabel, []], + offLabelFont: [settings.offLabelFont, []], + offLabelColor: [settings.offLabelColor, []], + + background: [settings.background, []] + }); + } + + protected validatorTriggers(): string[] { + return ['showLabel', 'showIcon', 'showOnLabel', 'showOffLabel']; + } + + protected updateValidators(_emitEvent: boolean): void { + const showLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showLabel').value; + const showIcon: boolean = this.singleSwitchWidgetSettingsForm.get('showIcon').value; + const showOnLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showOnLabel').value; + const showOffLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showOffLabel').value; + + if (showLabel) { + this.singleSwitchWidgetSettingsForm.get('label').enable(); + this.singleSwitchWidgetSettingsForm.get('labelFont').enable(); + this.singleSwitchWidgetSettingsForm.get('labelColor').enable(); + } else { + this.singleSwitchWidgetSettingsForm.get('label').disable(); + this.singleSwitchWidgetSettingsForm.get('labelFont').disable(); + this.singleSwitchWidgetSettingsForm.get('labelColor').disable(); + } + + if (showIcon) { + this.singleSwitchWidgetSettingsForm.get('iconSize').enable(); + this.singleSwitchWidgetSettingsForm.get('iconSizeUnit').enable(); + this.singleSwitchWidgetSettingsForm.get('icon').enable(); + this.singleSwitchWidgetSettingsForm.get('iconColor').enable(); + } else { + this.singleSwitchWidgetSettingsForm.get('iconSize').disable(); + this.singleSwitchWidgetSettingsForm.get('iconSizeUnit').disable(); + this.singleSwitchWidgetSettingsForm.get('icon').disable(); + this.singleSwitchWidgetSettingsForm.get('iconColor').disable(); + } + + if (showOnLabel) { + this.singleSwitchWidgetSettingsForm.get('onLabel').enable(); + this.singleSwitchWidgetSettingsForm.get('onLabelFont').enable(); + this.singleSwitchWidgetSettingsForm.get('onLabelColor').enable(); + } else { + this.singleSwitchWidgetSettingsForm.get('onLabel').disable(); + this.singleSwitchWidgetSettingsForm.get('onLabelFont').disable(); + this.singleSwitchWidgetSettingsForm.get('onLabelColor').disable(); + } + + if (showOffLabel) { + this.singleSwitchWidgetSettingsForm.get('offLabel').enable(); + this.singleSwitchWidgetSettingsForm.get('offLabelFont').enable(); + this.singleSwitchWidgetSettingsForm.get('offLabelColor').enable(); + } else { + this.singleSwitchWidgetSettingsForm.get('offLabel').disable(); + this.singleSwitchWidgetSettingsForm.get('offLabelFont').disable(); + this.singleSwitchWidgetSettingsForm.get('offLabelColor').disable(); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html index 8d7f4243b4..1bf741eba7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html @@ -1,6 +1,6 @@ + +
+
widgets.slider.behavior
+
+
widgets.slider.initial-value
+ +
+
+
widgets.slider.on-value-change
+ +
+
+
widgets.rpc-state.disabled-state
+ +
+
+
+
widget-config.card-style
+ + + {{ sliderLayoutTranslationMap.get(layout) | translate }} + + +
+ + {{ 'widgets.slider.auto-scale' | translate }} + +
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
+
widgets.slider.slider
+
+ + {{ 'widgets.slider.value' | translate }} + +
+ + + +
widget-config.decimals-suffix
+
+ + + + +
+
+
+
{{ 'widgets.slider.range' | translate }}
+
+
widgets.slider.min
+ + + +
widgets.slider.max
+ + + +
+
+
+ + {{ 'widgets.slider.range-ticks' | translate }} + +
+ + + + +
+
+
+ + {{ 'widgets.slider.tick-marks' | translate }} + +
+ + + + + +
+
+
+
{{ 'widgets.slider.colors' | translate }}
+
+
+
widgets.slider.main
+ + +
+ +
+
widgets.slider.background
+ + +
+
+
+
+
{{ 'widgets.rpc-state.disabled-state' | translate }}
+
+
+
widgets.slider.main
+ + +
+ +
+
widgets.slider.background
+ + +
+
+
+
+
+ {{ 'widgets.slider.left-icon' | translate }} +
+
+ + + + + + + + +
+
+
+
+ {{ 'widgets.slider.right-icon' | translate }} +
+
+ + + + + + + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts new file mode 100644 index 0000000000..a215a93d88 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts @@ -0,0 +1,186 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ValueType } from '@shared/models/constants'; +import { + SliderLayout, + sliderLayoutImages, + sliderLayouts, + sliderLayoutTranslations, + sliderWidgetDefaultSettings +} from '@home/components/widget/lib/rpc/slider-widget.models'; +import { formatValue } from '@core/utils'; + +@Component({ + selector: 'tb-slider-widget-settings', + templateUrl: './slider-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class SliderWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + return this.widgetConfig?.config?.targetDevice; + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + + sliderLayout = SliderLayout; + + sliderLayouts = sliderLayouts; + + sliderLayoutTranslationMap = sliderLayoutTranslations; + sliderLayoutImageMap = sliderLayoutImages; + + valueType = ValueType; + + sliderWidgetSettingsForm: UntypedFormGroup; + + valuePreviewFn = this._valuePreviewFn.bind(this); + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.sliderWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...sliderWidgetDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.sliderWidgetSettingsForm = this.fb.group({ + initialState: [settings.initialState, []], + valueChange: [settings.valueChange, []], + disabledState: [settings.disabledState, []], + + layout: [settings.layout, []], + autoScale: [settings.autoScale, []], + + showValue: [settings.showValue, []], + valueUnits: [settings.valueUnits, []], + valueDecimals: [settings.valueDecimals, []], + valueFont: [settings.valueFont, []], + valueColor: [settings.valueColor, []], + + tickMin: [settings.tickMin, []], + tickMax: [settings.tickMax, []], + + showTicks: [settings.showTicks, []], + ticksFont: [settings.ticksFont, []], + ticksColor: [settings.ticksColor, []], + + showTickMarks: [settings.showTickMarks, []], + tickMarksCount: [settings.tickMarksCount, [Validators.min(2)]], + tickMarksColor: [settings.tickMarksColor, []], + + mainColor: [settings.mainColor, []], + backgroundColor: [settings.backgroundColor, []], + + mainColorDisabled: [settings.mainColorDisabled, []], + backgroundColorDisabled: [settings.backgroundColorDisabled, []], + + leftIconSize: [settings.leftIconSize, [Validators.min(0)]], + leftIconSizeUnit: [settings.leftIconSizeUnit, []], + leftIcon: [settings.leftIcon, []], + leftIconColor: [settings.leftIconColor, []], + + rightIconSize: [settings.rightIconSize, [Validators.min(0)]], + rightIconSizeUnit: [settings.rightIconSizeUnit, []], + rightIcon: [settings.rightIcon, []], + rightIconColor: [settings.rightIconColor, []], + + background: [settings.background, []], + }); + } + + protected validatorTriggers(): string[] { + return ['showValue', 'showTicks', 'showTickMarks', 'layout']; + } + + protected updateValidators(_emitEvent: boolean): void { + const showValue: boolean = this.sliderWidgetSettingsForm.get('showValue').value; + const showTicks: boolean = this.sliderWidgetSettingsForm.get('showTicks').value; + const showTickMarks: boolean = this.sliderWidgetSettingsForm.get('showTickMarks').value; + const layout: SliderLayout = this.sliderWidgetSettingsForm.get('layout').value; + + const valueEnabled = layout !== SliderLayout.simplified; + const leftRightIconsEnabled = layout === SliderLayout.extended; + + if (valueEnabled && showValue) { + this.sliderWidgetSettingsForm.get('valueUnits').enable(); + this.sliderWidgetSettingsForm.get('valueDecimals').enable(); + this.sliderWidgetSettingsForm.get('valueFont').enable(); + this.sliderWidgetSettingsForm.get('valueColor').enable(); + } else { + this.sliderWidgetSettingsForm.get('valueUnits').disable(); + this.sliderWidgetSettingsForm.get('valueDecimals').disable(); + this.sliderWidgetSettingsForm.get('valueFont').disable(); + this.sliderWidgetSettingsForm.get('valueColor').disable(); + } + + if (showTicks) { + this.sliderWidgetSettingsForm.get('ticksFont').enable(); + this.sliderWidgetSettingsForm.get('ticksColor').enable(); + } else { + this.sliderWidgetSettingsForm.get('ticksFont').disable(); + this.sliderWidgetSettingsForm.get('ticksColor').disable(); + } + + if (showTickMarks) { + this.sliderWidgetSettingsForm.get('tickMarksCount').enable(); + this.sliderWidgetSettingsForm.get('tickMarksColor').enable(); + } else { + this.sliderWidgetSettingsForm.get('tickMarksCount').disable(); + this.sliderWidgetSettingsForm.get('tickMarksColor').disable(); + } + + if (leftRightIconsEnabled) { + this.sliderWidgetSettingsForm.get('leftIconSize').enable(); + this.sliderWidgetSettingsForm.get('leftIconSizeUnit').enable(); + this.sliderWidgetSettingsForm.get('leftIcon').enable(); + this.sliderWidgetSettingsForm.get('leftIconColor').enable(); + this.sliderWidgetSettingsForm.get('rightIconSize').enable(); + this.sliderWidgetSettingsForm.get('rightIconSizeUnit').enable(); + this.sliderWidgetSettingsForm.get('rightIcon').enable(); + this.sliderWidgetSettingsForm.get('rightIconColor').enable(); + } else { + this.sliderWidgetSettingsForm.get('leftIconSize').disable(); + this.sliderWidgetSettingsForm.get('leftIconSizeUnit').disable(); + this.sliderWidgetSettingsForm.get('leftIcon').disable(); + this.sliderWidgetSettingsForm.get('leftIconColor').disable(); + this.sliderWidgetSettingsForm.get('rightIconSize').disable(); + this.sliderWidgetSettingsForm.get('rightIconSizeUnit').disable(); + this.sliderWidgetSettingsForm.get('rightIcon').disable(); + this.sliderWidgetSettingsForm.get('rightIconColor').disable(); + } + } + + private _valuePreviewFn(): string { + const units: string = this.sliderWidgetSettingsForm.get('valueUnits').value; + const decimals: number = this.sliderWidgetSettingsForm.get('valueDecimals').value; + return formatValue(48, decimals, units, false); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html index 5556540f4d..f50ec5c903 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-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 bf43218f82..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. @@ -47,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; @@ -87,7 +90,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft centerValueColor: ColorProcessor; - backgroundStyle: ComponentStyle = {}; + backgroundStyle$: Observable; overlayStyle: ComponentStyle = {}; shapeResize$: ResizeObserver; @@ -109,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) { } @@ -135,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; 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 6359696f25..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. 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 313993167d..f1801561bd 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,9 @@ 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'; +import { TbTimeSeriesChart } from '@home/components/widget/lib/chart/time-series-chart'; @Injectable() export class WidgetComponentService { @@ -140,6 +149,11 @@ export class WidgetComponentService { (window as any).TbFlot = mod.TbFlot; })) ); + widgetModulesTasks.push(from(import('@home/components/widget/lib/chart/time-series-chart')).pipe( + tap((mod) => { + (window as any).TbTimeSeriesChart = mod.TbTimeSeriesChart; + })) + ); widgetModulesTasks.push(from(import('@home/components/widget/lib/analogue-compass')).pipe( tap((mod) => { (window as any).TbAnalogueCompass = mod.TbAnalogueCompass; @@ -397,6 +411,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 +422,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; }); } } @@ -549,6 +571,9 @@ export class WidgetComponentService { if (isUndefined(result.typeParameters.embedTitlePanel)) { result.typeParameters.embedTitlePanel = false; } + if (isUndefined(result.typeParameters.overflowVisible)) { + result.typeParameters.overflowVisible = false; + } if (isUndefined(result.typeParameters.hideDataSettings)) { result.typeParameters.hideDataSettings = false; } @@ -558,6 +583,12 @@ export class WidgetComponentService { if (!isFunction(result.typeParameters.defaultLatestDataKeysFunction)) { result.typeParameters.defaultLatestDataKeysFunction = null; } + if (!isFunction(result.typeParameters.dataKeySettingsFunction)) { + result.typeParameters.dataKeySettingsFunction = null; + } + if (isUndefined(result.typeParameters.displayRpcMessageToast)) { + result.typeParameters.displayRpcMessageToast = true; + } 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 858856fb4a..0ae45d1eff 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. @@ -66,6 +66,17 @@ import { ValueChartCardWidgetComponent } from '@home/components/widget/lib/cards 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'; +import { SingleSwitchWidgetComponent } from '@home/components/widget/lib/rpc/single-switch-widget.component'; +import { ActionButtonWidgetComponent } from '@home/components/widget/lib/button/action-button-widget.component'; +import { CommandButtonWidgetComponent } from '@home/components/widget/lib/button/command-button-widget.component'; +import { PowerButtonWidgetComponent } from '@home/components/widget/lib/rpc/power-button-widget.component'; +import { SliderWidgetComponent } from '@home/components/widget/lib/rpc/slider-widget.component'; +import { ToggleButtonWidgetComponent } from '@home/components/widget/lib/button/toggle-button-widget.component'; +import { TimeSeriesChartWidgetComponent } from '@home/components/widget/lib/chart/time-series-chart-widget.component'; @NgModule({ declarations: @@ -106,7 +117,16 @@ import { DoughnutWidgetComponent } from '@home/components/widget/lib/chart/dough ValueChartCardWidgetComponent, ProgressBarWidgetComponent, LiquidLevelWidgetComponent, - DoughnutWidgetComponent + DoughnutWidgetComponent, + RangeChartWidgetComponent, + BarChartWithLabelsWidgetComponent, + SingleSwitchWidgetComponent, + ActionButtonWidgetComponent, + CommandButtonWidgetComponent, + PowerButtonWidgetComponent, + SliderWidgetComponent, + ToggleButtonWidgetComponent, + TimeSeriesChartWidgetComponent ], imports: [ CommonModule, @@ -151,7 +171,16 @@ import { DoughnutWidgetComponent } from '@home/components/widget/lib/chart/dough ValueChartCardWidgetComponent, ProgressBarWidgetComponent, LiquidLevelWidgetComponent, - DoughnutWidgetComponent + DoughnutWidgetComponent, + RangeChartWidgetComponent, + BarChartWithLabelsWidgetComponent, + SingleSwitchWidgetComponent, + ActionButtonWidgetComponent, + CommandButtonWidgetComponent, + PowerButtonWidgetComponent, + SliderWidgetComponent, + ToggleButtonWidgetComponent, + TimeSeriesChartWidgetComponent ], 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..300a7f54d1 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 @@ + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss index 66df772d2d..b8b195dff2 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.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,5 +14,12 @@ * limitations under the License. */ :host { + .mat-mdc-card-header { + align-items: center; + min-height: 64px; + } + .mdc-card__actions { + padding: 16px; + } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts index e56f02c4e4..f59cd2bfc4 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.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. @@ -26,7 +26,7 @@ import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { MatDialog } from '@angular/material/dialog'; import { SendTestSmsDialogComponent, SendTestSmsDialogData } from '@home/pages/admin/send-test-sms-dialog.component'; import { NotificationSettings } from '@shared/models/notification.models'; -import { deepTrim, isEmptyStr } from '@core/utils'; +import { deepTrim, isNotEmptyStr } from '@core/utils'; import { NotificationService } from '@core/http/notification.service'; import { Authority } from '@shared/models/authority.enum'; import { AuthUser } from '@shared/models/user.model'; @@ -42,7 +42,7 @@ export class SmsProviderComponent extends PageComponent implements HasConfirmFor smsProvider: FormGroup; private adminSettings: AdminSettings; - slackSettingsForm: FormGroup; + notificationSettingsForm: FormGroup; private notificationSettings: NotificationSettings; private readonly authUser: AuthUser; @@ -60,7 +60,7 @@ export class SmsProviderComponent extends PageComponent implements HasConfirmFor this.notificationService.getNotificationSettings().subscribe( (settings) => { this.notificationSettings = settings; - this.slackSettingsForm.reset(this.notificationSettings); + this.notificationSettingsForm.reset(this.notificationSettings); } ); if (this.isSysAdmin()) { @@ -108,29 +108,33 @@ export class SmsProviderComponent extends PageComponent implements HasConfirmFor } confirmForm(): FormGroup { - return this.smsProvider.dirty ? this.smsProvider : this.slackSettingsForm; + return this.smsProvider.dirty ? this.smsProvider : this.notificationSettingsForm; } private buildGeneralServerSettingsForm() { - this.slackSettingsForm = this.fb.group({ + this.notificationSettingsForm = this.fb.group({ deliveryMethodsConfigs: this.fb.group({ SLACK: this.fb.group({ botToken: [''] + }), + MOBILE_APP: this.fb.group({ + firebaseServiceAccountCredentialsFileName: [''], + firebaseServiceAccountCredentials: [''] }) }) }); - this.registerDisableOnLoadFormControl(this.slackSettingsForm.get('deliveryMethodsConfigs')); + this.registerDisableOnLoadFormControl(this.notificationSettingsForm.get('deliveryMethodsConfigs')); } saveNotification(): void { this.notificationSettings = deepTrim({ ...this.notificationSettings, - ...this.slackSettingsForm.value + ...this.notificationSettingsForm.value }); // eslint-disable-next-line guard-for-in for (const method in this.notificationSettings.deliveryMethodsConfigs) { const keys = Object.keys(this.notificationSettings.deliveryMethodsConfigs[method]); - if (keys.some(item => isEmptyStr(this.notificationSettings.deliveryMethodsConfigs[method][item]))) { + if (keys.some(item => !isNotEmptyStr(this.notificationSettings.deliveryMethodsConfigs[method][item]))) { delete this.notificationSettings.deliveryMethodsConfigs[method]; } else { this.notificationSettings.deliveryMethodsConfigs[method].method = method; @@ -138,7 +142,7 @@ export class SmsProviderComponent extends PageComponent implements HasConfirmFor } this.notificationService.saveNotificationSettings(this.notificationSettings).subscribe(setting => { this.notificationSettings = setting; - this.slackSettingsForm.reset(this.notificationSettings); + this.notificationSettingsForm.reset(this.notificationSettings); }); } diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html index 69cacf3604..d2e6419479 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -1,6 +1,6 @@ +
+ + + +
+ + {{ actionTitle }} + +
+
+
+ +
+ + notification.button-text + + + {{ 'notification.button-text-required' | translate }} + + + {{ 'notification.button-text-max-length' | translate : + {length: actionButtonConfigForm.get('text').getError('maxlength').requiredLength} + }} + + +
+
+ + notification.action-type + + + {{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }} + + + + + notification.link + + + {{ 'notification.link-required' | translate }} + + + + + + + + +
+ + {{ 'notification.set-entity-from-notification' | translate }} + +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.ts new file mode 100644 index 0000000000..e62d647694 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.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 { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { ActionButtonLinkType, ActionButtonLinkTypeTranslateMap } from '@shared/models/notification.models'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { isDefinedAndNotNull } from '@core/utils'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-notification-action-button-configuration', + templateUrl: './notification-action-button-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NotificationActionButtonConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NotificationActionButtonConfigurationComponent), + multi: true, + } + ] +}) +export class NotificationActionButtonConfigurationComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { + + @Input() + actionTitle: string; + + @Input() + sliderHint: string; + + private hideButtonTextValue = false; + + get hideButtonText(): boolean { + return this.hideButtonTextValue; + } + + @Input() + @coerceBoolean() + set hideButtonText(hideButtonText: boolean) { + this.hideButtonTextValue = hideButtonText; + if (this.hideButtonTextValue) { + this.actionButtonConfigForm.removeControl('text'); + } + } + + actionButtonConfigForm: FormGroup; + + readonly actionButtonLinkType = ActionButtonLinkType; + readonly actionButtonLinkTypes = Object.keys(ActionButtonLinkType) as ActionButtonLinkType[]; + readonly actionButtonLinkTypeTranslateMap = ActionButtonLinkTypeTranslateMap; + + private propagateChange = (v: any) => { }; + private readonly destroy$ = new Subject(); + + constructor(private fb: FormBuilder) { + this.actionButtonConfigForm = this.fb.group({ + enabled: [false], + text: [{value: '', disabled: true}, [Validators.required, Validators.maxLength(50)]], + linkType: [ActionButtonLinkType.LINK], + link: [{value: '', disabled: true}, Validators.required], + dashboardId: [{value: null, disabled: true}, Validators.required], + dashboardState: [{value: null, disabled: true}], + setEntityIdInState: [{value: true, disabled: true}] + }); + + + this.actionButtonConfigForm.get('enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + if (value) { + if (!this.hideButtonText) { + this.actionButtonConfigForm.get('text').enable({emitEvent: false}); + } + this.actionButtonConfigForm.get('linkType').enable({onlySelf: false}); + } else { + this.actionButtonConfigForm.disable({emitEvent: false}); + this.actionButtonConfigForm.get('enabled').enable({emitEvent: false}); + } + }); + + this.actionButtonConfigForm.get('linkType').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + const isEnabled = this.actionButtonConfigForm.get('enabled').value; + if (isEnabled) { + if (value === ActionButtonLinkType.LINK) { + this.actionButtonConfigForm.get('link').enable({emitEvent: false}); + this.actionButtonConfigForm.get('dashboardId').disable({emitEvent: false}); + this.actionButtonConfigForm.get('dashboardState').disable({emitEvent: false}); + this.actionButtonConfigForm.get('setEntityIdInState').disable({emitEvent: false}); + } else { + this.actionButtonConfigForm.get('link').disable({emitEvent: false}); + this.actionButtonConfigForm.get('dashboardId').enable({emitEvent: false}); + this.actionButtonConfigForm.get('dashboardState').enable({emitEvent: false}); + this.actionButtonConfigForm.get('setEntityIdInState').enable({emitEvent: false}); + } + } + }); + + this.actionButtonConfigForm.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => this.propagateChange(value)); + } + + ngOnInit() { + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: any) { + this.propagateChange = fn; + } + + registerOnTouched(fn: any) { + } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.actionButtonConfigForm.disable({emitEvent: false}); + } else { + this.actionButtonConfigForm.enable({emitEvent: false}); + this.actionButtonConfigForm.get('enabled').updateValueAndValidity({onlySelf: true}); + } + } + + validate(): ValidationErrors | null { + return this.actionButtonConfigForm.valid ? null : { + actionButtonConfigForm: { + valid: false + } + }; + } + + writeValue(obj) { + if (isDefinedAndNotNull(obj)) { + this.actionButtonConfigForm.patchValue(obj, {emitEvent: false}); + this.actionButtonConfigForm.get('enabled').updateValueAndValidity({onlySelf: true}); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html new file mode 100644 index 0000000000..88d31996ca --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html @@ -0,0 +1,276 @@ + +
+
notification.customize-messages
+
+ {{ 'notification.input-fields-support-templatization' | translate}} + +
+
+
+
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).name | translate }} + + + + + notification.subject + + + {{ 'notification.subject-required' | translate }} + + + + notification.message + + + {{ 'notification.message-required' | translate }} + + +
+
+ + {{ 'icon.icon' | translate }} + +
+ + + + +
+
+ + +
+
+
+
+
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).name | translate }} + + + + + notification.subject + + + {{ 'notification.subject-required' | translate }} + + + {{ 'notification.subject-max-length' | translate : + {length: templateConfigurationForm.get('MOBILE_APP.subject').getError('maxlength').requiredLength} + }} + + + + notification.message + + + {{ 'notification.message-required' | translate }} + + + {{ 'notification.message-max-length' | translate : + {length: templateConfigurationForm.get('MOBILE_APP.body').getError('maxlength').requiredLength} + }} + + +
+
+ + {{ 'icon.icon' | translate }} + +
+ + + + +
+
+ + +
+
+
+
+
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).name | translate }} + + + + + notification.message + + + + {{ 'notification.message-required' | translate }} + + + {{ 'notification.message-max-length' | translate : + {length: templateConfigurationForm.get('SMS.body').getError('maxlength').requiredLength} + }} + + + + +
+
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).name | translate }} + + + + + notification.subject + + + {{ 'notification.subject-required' | translate }} + + + notification.message + + + + {{ 'notification.message-required' | translate }} + + + +
+
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).name | translate }} + + + + + notification.message + + + {{ 'notification.message-required' | translate }} + + + + +
+
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).name | translate }} + + + + + notification.subject + + + + notification.message + + + {{ 'notification.message-required' | translate }} + + +
+
+
notification.theme-color
+ +
+ + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.scss b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.scss new file mode 100644 index 0000000000..eed548f07b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.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. + */ +:host { + .tb-template-header { + position: sticky; + top: 0; + z-index: 5; + padding-bottom: 12px; + background-color: var(--mdc-dialog-container-color, white); + + .tb-hint-available-params { + color: rgba(0, 0, 0, 0.54); + letter-spacing: 0.4px; + + .content { + vertical-align: middle; + } + } + } + + .template-tittle { + gap: 12px; + font-weight: normal; + + tb-icon { + color: rgba(0, 0, 0, .38); + } + } + + .tb-mat-error { + font-size: 13px; + } + + .tb-title { + font-size: 16px; + line-height: 24px; + + &.tb-required::after { + font-size: initial; + content: "*"; + } + + &.tb-error { + color: var(--mdc-theme-error, #f44336); + + &.tb-required::after { + color: var(--mdc-theme-error, #f44336); + } + } + } +} + +:host ::ng-deep { + .tb-form-panel .mat-expansion-panel.tb-settings { + & > .mat-expansion-panel-content > .mat-expansion-panel-body { + gap: 0; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts new file mode 100644 index 0000000000..eac2dc5a7e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts @@ -0,0 +1,290 @@ +/// +/// 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 } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + DeliveryMethodsTemplates, + NotificationDeliveryMethod, + NotificationDeliveryMethodInfoMap, + NotificationTemplateTypeTranslateMap, + NotificationType +} from '@shared/models/notification.models'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-template-configuration', + templateUrl: './notification-template-configuration.component.html', + styleUrls: ['./notification-template-configuration.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NotificationTemplateConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NotificationTemplateConfigurationComponent), + multi: true, + } + ] +}) +export class NotificationTemplateConfigurationComponent implements OnDestroy, ControlValueAccessor, Validator { + + templateConfigurationForm: FormGroup; + + NotificationDeliveryMethodInfoMap = NotificationDeliveryMethodInfoMap; + + @Input() + set predefinedDeliveryMethodsTemplate(value: Partial) { + if (isDefinedAndNotNull(value)) { + this.templateConfigurationForm.patchValue(value, {emitEvent: false}); + this.updateDisabledForms(); + this.updateExpandedForm(); + this.templateConfigurationForm.updateValueAndValidity(); + } + } + + @Input() + notificationType: NotificationType; + + @Input() + @coerceBoolean() + interacted: boolean; + + readonly NotificationDeliveryMethod = NotificationDeliveryMethod; + readonly NotificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap; + + tinyMceOptions: Record = { + base_url: '/assets/tinymce', + suffix: '.min', + plugins: ['link table image imagetools code fullscreen'], + menubar: 'edit insert tools view format table', + toolbar: 'fontselect fontsizeselect | formatselect | bold italic strikethrough forecolor backcolor ' + + '| link | table | image | alignleft aligncenter alignright alignjustify ' + + '| numlist bullist outdent indent | removeformat | code | fullscreen', + toolbar_mode: 'sliding', + height: 400, + autofocus: false, + branding: false + }; + + private propagateChange = (v: any) => { }; + private readonly destroy$ = new Subject(); + private expendedBlocks: NotificationDeliveryMethod[]; + + constructor(private fb: FormBuilder, + private translate: TranslateService) { + this.templateConfigurationForm = this.buildForm(); + this.templateConfigurationForm.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.propagateChange(value); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + writeValue(value: Partial) { + const settings = deepClone(value); + if (isDefinedAndNotNull(settings)) { + for (const method of Object.values(NotificationDeliveryMethod)) { + if (isDefinedAndNotNull(settings[method]?.enabled)) { + delete settings[method].enabled; + } + } + } + this.templateConfigurationForm.patchValue(settings, {emitEvent: false}); + this.updateExpandedForm(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.templateConfigurationForm.disable({emitEvent: false}); + } else { + this.updateDisabledForms(); + } + } + + validate(): ValidationErrors { + return this.templateConfigurationForm.valid ? null : { + templateConfiguration: { + valid: false, + }, + }; + } + + get notificationTapActionHint(): string { + switch (this.notificationType) { + case NotificationType.ALARM: + case NotificationType.ALARM_ASSIGNMENT: + case NotificationType.ALARM_COMMENT: + return this.translate.instant('notification.notification-tap-action-hint'); + } + return ''; + } + + expandedForm(name: NotificationDeliveryMethod): boolean { + return this.expendedBlocks.includes(name); + } + + private updateExpandedForm() { + this.expendedBlocks = []; + Object.keys(this.templateConfigurationForm.controls).forEach((name: NotificationDeliveryMethod) => { + if (this.templateConfigurationForm.get(name).invalid) { + this.expendedBlocks.push(name); + } + }); + } + + private updateDisabledForms(){ + Object.values(NotificationDeliveryMethod).forEach((method) => { + const form = this.templateConfigurationForm.get(method); + if (!form.get('enabled').value) { + form.disable({emitEvent: false}); + } else { + form.enable({emitEvent: false}); + switch (method) { + case NotificationDeliveryMethod.WEB: + form.get('additionalConfig.icon.enabled').updateValueAndValidity({onlySelf: true}); + break; + case NotificationDeliveryMethod.MOBILE_APP: + form.get('additionalConfig.icon.enabled').updateValueAndValidity({onlySelf: true}); + break; + } + } + }); + } + + private buildForm(): FormGroup { + const form = this.fb.group({}); + + Object.values(NotificationDeliveryMethod).forEach((method) => { + form.addControl(method, this.buildDeliveryMethodControl(method), {emitEvent: false}); + }); + + return form; + } + + private buildDeliveryMethodControl(deliveryMethod: NotificationDeliveryMethod): FormGroup { + let deliveryMethodForm: FormGroup; + switch (deliveryMethod) { + case NotificationDeliveryMethod.WEB: + deliveryMethodForm = this.fb.group({ + subject: ['', Validators.required], + body: ['', Validators.required], + additionalConfig: this.fb.group({ + icon: this.fb.group({ + enabled: [false], + icon: [{value: 'notifications', disabled: true}, Validators.required], + color: [{value: '#757575', disabled: true}] + }), + actionButtonConfig: [null] + }) + }); + + deliveryMethodForm.get('additionalConfig.icon.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + if (value) { + deliveryMethodForm.get('additionalConfig.icon.icon').enable({emitEvent: false}); + deliveryMethodForm.get('additionalConfig.icon.color').enable({emitEvent: false}); + } else { + deliveryMethodForm.get('additionalConfig.icon.icon').disable({emitEvent: false}); + deliveryMethodForm.get('additionalConfig.icon.color').disable({emitEvent: false}); + } + }); + break; + case NotificationDeliveryMethod.EMAIL: + deliveryMethodForm = this.fb.group({ + subject: ['', Validators.required], + body: ['', Validators.required] + }); + break; + case NotificationDeliveryMethod.SMS: + deliveryMethodForm = this.fb.group({ + body: ['', [Validators.required, Validators.maxLength(320)]] + }); + break; + case NotificationDeliveryMethod.SLACK: + deliveryMethodForm = this.fb.group({ + body: ['', Validators.required] + }); + break; + case NotificationDeliveryMethod.MOBILE_APP: + deliveryMethodForm = this.fb.group({ + subject: ['', [Validators.required, Validators.maxLength(50)]], + body: ['', [Validators.required, Validators.maxLength(150)]], + additionalConfig: this.fb.group({ + icon: this.fb.group({ + enabled: [false], + icon: [{value: 'notifications', disabled: true}, Validators.required], + color: [{value: '#757575', disabled: true}] + }), + onClick: [null] + }) + }); + deliveryMethodForm.get('additionalConfig.icon.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + if (value) { + deliveryMethodForm.get('additionalConfig.icon.icon').enable({emitEvent: false}); + deliveryMethodForm.get('additionalConfig.icon.color').enable({emitEvent: false}); + } else { + deliveryMethodForm.get('additionalConfig.icon.icon').disable({emitEvent: false}); + deliveryMethodForm.get('additionalConfig.icon.color').disable({emitEvent: false}); + } + }); + break; + case NotificationDeliveryMethod.MICROSOFT_TEAMS: + deliveryMethodForm = this.fb.group({ + subject: [''], + body: ['', Validators.required], + themeColor: [''], + button: [null] + }); + break; + default: + throw new Error(`Not configured templated for notification delivery method: ${deliveryMethod}`); + } + deliveryMethodForm.addControl('enabled', this.fb.control(false), {emitEvent: false}); + deliveryMethodForm.addControl('method', this.fb.control(deliveryMethod), {emitEvent: false}); + return deliveryMethodForm; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.ts b/ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.ts index c2dc5355e8..3da842661d 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.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. @@ -16,10 +16,9 @@ import { FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms'; import { - ActionButtonLinkType, - ActionButtonLinkTypeTranslateMap, + DeliveryMethodsTemplates, NotificationDeliveryMethod, - NotificationDeliveryMethodTranslateMap, + NotificationDeliveryMethodInfoMap, NotificationTemplate, NotificationTemplateTypeTranslateMap, NotificationType @@ -27,44 +26,25 @@ import { import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; import { Directive, OnDestroy } from '@angular/core'; +import { deepClone, deepTrim } from '@core/utils'; import { DialogComponent } from '@shared/components/dialog.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MatDialogRef } from '@angular/material/dialog'; -import { deepClone, deepTrim } from '@core/utils'; @Directive() // tslint:disable-next-line:directive-class-suffix export abstract class TemplateConfiguration extends DialogComponent implements OnDestroy{ templateNotificationForm: FormGroup; - webTemplateForm: FormGroup; - emailTemplateForm: FormGroup; - smsTemplateForm: FormGroup; - slackTemplateForm: FormGroup; - microsoftTeamsTemplateForm: FormGroup; + notificationTemplateConfigurationForm: FormGroup; notificationDeliveryMethods = Object.keys(NotificationDeliveryMethod) as NotificationDeliveryMethod[]; - notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap; + notificationDeliveryMethodInfoMap = NotificationDeliveryMethodInfoMap; notificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap; - actionButtonLinkType = ActionButtonLinkType; - actionButtonLinkTypes = Object.keys(ActionButtonLinkType) as ActionButtonLinkType[]; - actionButtonLinkTypeTranslateMap = ActionButtonLinkTypeTranslateMap; - - tinyMceOptions: Record = { - base_url: '/assets/tinymce', - suffix: '.min', - plugins: ['link table image imagetools code fullscreen'], - menubar: 'edit insert tools view format table', - toolbar: 'fontselect fontsizeselect | formatselect | bold italic strikethrough forecolor backcolor ' + - '| link | table | image | alignleft aligncenter alignright alignjustify ' + - '| numlist bullist outdent indent | removeformat | code | fullscreen', - height: 400, - autofocus: false, - branding: false - }; + deliveryConfiguration: Partial; protected readonly destroy$ = new Subject(); @@ -84,63 +64,22 @@ export abstract class TemplateConfiguration extends DialogComponent< }) }); - this.notificationDeliveryMethods.forEach(method => { - (this.templateNotificationForm.get('configuration.deliveryMethodsTemplates') as FormGroup) - .addControl(method, this.fb.group({enabled: method === NotificationDeliveryMethod.WEB}), {emitEvent: false}); - }); - - this.webTemplateForm = this.fb.group({ - subject: ['', Validators.required], - body: ['', Validators.required], - additionalConfig: this.fb.group({ - icon: this.fb.group({ - enabled: [false], - icon: [{value: 'notifications', disabled: true}, Validators.required], - color: [{value: '#757575', disabled: true}] - }), - actionButtonConfig: this.createButtonConfigForm() - }) - }); - - this.webTemplateForm.get('additionalConfig.icon.enabled').valueChanges.pipe( + this.templateNotificationForm.get('configuration.deliveryMethodsTemplates').valueChanges.pipe( takeUntil(this.destroy$) ).subscribe((value) => { - if (value) { - this.webTemplateForm.get('additionalConfig.icon.icon').enable({emitEvent: false}); - this.webTemplateForm.get('additionalConfig.icon.color').enable({emitEvent: false}); - } else { - this.webTemplateForm.get('additionalConfig.icon.icon').disable({emitEvent: false}); - this.webTemplateForm.get('additionalConfig.icon.color').disable({emitEvent: false}); - } - }); - - this.emailTemplateForm = this.fb.group({ - subject: ['', Validators.required], - body: ['', Validators.required] + this.deliveryConfiguration = value; }); - this.smsTemplateForm = this.fb.group({ - body: ['', [Validators.required, Validators.maxLength(320)]] + this.notificationTemplateConfigurationForm = this.fb.group({ + deliveryMethodsTemplates: null }); - this.slackTemplateForm = this.fb.group({ - body: ['', Validators.required] - }); - - this.microsoftTeamsTemplateForm = this.fb.group({ - subject: [''], - body: ['', Validators.required], - themeColor: [''], - button: this.createButtonConfigForm() + this.notificationDeliveryMethods.forEach(method => { + (this.templateNotificationForm.get('configuration.deliveryMethodsTemplates') as FormGroup) + .addControl(method, this.fb.group({enabled: method === NotificationDeliveryMethod.WEB}), {emitEvent: false}); }); - this.deliveryMethodFormsMap = new Map([ - [NotificationDeliveryMethod.WEB, this.webTemplateForm], - [NotificationDeliveryMethod.EMAIL, this.emailTemplateForm], - [NotificationDeliveryMethod.SMS, this.smsTemplateForm], - [NotificationDeliveryMethod.SLACK, this.slackTemplateForm], - [NotificationDeliveryMethod.MICROSOFT_TEAMS, this.microsoftTeamsTemplateForm] - ]); + this.deliveryConfiguration = this.templateNotificationForm.get('configuration.deliveryMethodsTemplates').value; } ngOnDestroy() { @@ -160,58 +99,8 @@ export abstract class TemplateConfiguration extends DialogComponent< } protected getNotificationTemplateValue(): NotificationTemplate { - const template: NotificationTemplate = deepClone(this.templateNotificationForm.value); - this.notificationDeliveryMethods.forEach(method => { - if (template.configuration.deliveryMethodsTemplates[method]?.enabled) { - Object.assign(template.configuration.deliveryMethodsTemplates[method], this.deliveryMethodFormsMap.get(method).value, {method}); - } else { - delete template.configuration.deliveryMethodsTemplates[method]; - } - }); + const template = deepClone(this.templateNotificationForm.value); + template.configuration = deepClone(this.notificationTemplateConfigurationForm.value); return deepTrim(template); } - - private createButtonConfigForm(): FormGroup { - const form = this.fb.group({ - enabled: [false], - text: [{value: '', disabled: true}, [Validators.required, Validators.maxLength(50)]], - linkType: [ActionButtonLinkType.LINK], - link: [{value: '', disabled: true}, Validators.required], - dashboardId: [{value: null, disabled: true}, Validators.required], - dashboardState: [{value: null, disabled: true}], - setEntityIdInState: [{value: true, disabled: true}], - }); - - form.get('enabled').valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe((value) => { - if (value) { - form.enable({emitEvent: false}); - form.get('linkType').updateValueAndValidity({onlySelf: true}); - } else { - form.disable({emitEvent: false}); - form.get('enabled').enable({emitEvent: false}); - } - }); - - form.get('linkType').valueChanges.pipe( - takeUntil(this.destroy$) - ).subscribe((value) => { - const isEnabled = form.get('enabled').value; - if (isEnabled) { - if (value === ActionButtonLinkType.LINK) { - form.get('link').enable({emitEvent: false}); - form.get('dashboardId').disable({emitEvent: false}); - form.get('dashboardState').disable({emitEvent: false}); - form.get('setEntityIdInState').disable({emitEvent: false}); - } else { - form.get('link').disable({emitEvent: false}); - form.get('dashboardId').enable({emitEvent: false}); - form.get('dashboardState').enable({emitEvent: false}); - form.get('setEntityIdInState').enable({emitEvent: false}); - } - } - }); - return form; - } } diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html index 89cca4394a..2d3e642dd2 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html @@ -1,6 +1,6 @@ -
@@ -34,7 +34,7 @@ *ngIf="!isImport" [ruleChainType]="ruleChainType" [disabled]="isDirtyValue" - [(ngModel)]="ruleChain.id.id" + [(ngModel)]="ruleChain" (ngModelChange)="currentRuleChainIdChanged(ruleChain.id?.id)"> @@ -129,9 +129,9 @@
{{editingRuleNode.component.name}}
 
-
{{editingRuleNode.component.configurationDescriptor.nodeDefinition.description}}
+
 
-
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss index 636bfc5ffb..fc79a63ecf 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.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/pages/rulechain/rulechain-page.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts index 6f657f26c3..77e3602215 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.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. @@ -575,6 +575,7 @@ export class RuleChainPageComponent extends PageComponent configurationVersion: isDefinedAndNotNull(ruleNode.configurationVersion) ? ruleNode.configurationVersion : 0, debugMode: ruleNode.debugMode, singletonMode: ruleNode.singletonMode, + queueName: ruleNode.queueName, x: Math.round(ruleNode.additionalInfo.layoutX), y: Math.round(ruleNode.additionalInfo.layoutY), component, @@ -934,7 +935,8 @@ export class RuleChainPageComponent extends PageComponent configuration: deepClone(node.configuration), additionalInfo: node.additionalInfo ? deepClone(node.additionalInfo) : {}, debugMode: node.debugMode, - singletonMode: node.singletonMode + singletonMode: node.singletonMode, + queueName: node.queueName }; if (minX === null) { minX = node.x; @@ -1467,7 +1469,8 @@ export class RuleChainPageComponent extends PageComponent configuration: node.configuration, additionalInfo: node.additionalInfo ? node.additionalInfo : {}, debugMode: node.debugMode, - singletonMode: node.singletonMode + singletonMode: node.singletonMode, + queueName: node.queueName }; ruleNode.additionalInfo.layoutX = Math.round(node.x); ruleNode.additionalInfo.layoutY = Math.round(node.y); diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts index a26b2c0389..a48b08e792 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.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/pages/rulechain/rulechain-routing.module.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts index d8a22c67db..47fa4970c6 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.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. diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html index b865797cb6..ab1b0151b1 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html @@ -1,6 +1,6 @@
-
+
@@ -25,8 +25,8 @@ placeholder="{{ 'widget.widget-title' | translate }}"/> - + {{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }} @@ -240,11 +240,10 @@
- - + widget.description diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts index e6354945d7..79dbab63cf 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type.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/pages/widget/widget-types-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts index 0363aa6117..0ec7635c9d 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-types-table-config.resolver.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. @@ -31,16 +31,15 @@ import { WidgetService } from '@app/core/http/widget.service'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { getCurrentAuthState, getCurrentAuthUser } from '@app/core/auth/auth.selectors'; +import { getCurrentAuthUser } from '@app/core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; -import { DialogService } from '@core/services/dialog.service'; -import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { ImportExportService } from '@shared/import-export/import-export.service'; import { Direction } from '@shared/models/page/sort-order'; import { BaseWidgetType, + widgetType as WidgetDataType, WidgetTypeDetails, WidgetTypeInfo, - widgetType as WidgetDataType, widgetTypesData } from '@shared/models/widget.models'; import { WidgetTypeComponent } from '@home/pages/widget/widget-type.component'; @@ -134,7 +133,7 @@ export class WidgetTypesTableConfigResolver implements Resolve this.widgetsService.getWidgetTypeById(id.id); this.config.saveEntity = widgetType => this.widgetsService.saveWidgetType(widgetType as WidgetTypeDetails); this.config.deleteEntity = id => this.widgetsService.deleteWidgetType(id.id); - this.config.onEntityAction = action => this.onWidgetTypeAction(action); + this.config.onEntityAction = action => this.onWidgetTypeAction(action, this.config); this.config.handleRowClick = ($event, widgetType) => { if (this.config.isDetailsOpen()) { @@ -198,6 +197,14 @@ export class WidgetTypesTableConfigResolver implements Resolve) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree(['details', widgetType.id.id], {relativeTo: config.getActivatedRoute()}); + this.router.navigateByUrl(url); + } + exportWidgetType($event: Event, widgetType: BaseWidgetType) { if ($event) { $event.stopPropagation(); @@ -216,11 +223,14 @@ export class WidgetTypesTableConfigResolver implements Resolve): boolean { + onWidgetTypeAction(action: EntityAction, config: EntityTableConfig): boolean { switch (action.action) { case 'edit': this.openWidgetEditor(action.event, action.entity); return true; + case 'openDetails': + this.openWidgetTypeDetails(action.event, action.entity, config); + return true; case 'export': this.exportWidgetType(action.event, action.entity); return true; diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html index 83015604a3..b953969572 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html @@ -1,6 +1,6 @@ + diff --git a/ui-ngx/src/app/shared/components/button/widget-button.component.scss b/ui-ngx/src/app/shared/components/button/widget-button.component.scss new file mode 100644 index 0000000000..157f6b179a --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/widget-button.component.scss @@ -0,0 +1,193 @@ +/** + * 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. + */ +$defaultMainColor: #3F52DD; +$defaultBackgroundColor: #FFFFFF; +$defaultBoxShadowColor: rgba(0, 0, 0, 0.08); +$defaultDisabledBoxShadowColor: rgba(0, 0, 0, 0); + +$defaultMainColorDisabled: rgba(0, 0, 0, 0.38); +$defaultBackgroundColorDisabled: rgba(0, 0, 0, 0.03); + +$mainColorEnabled: var(--tb-widget-button-main-color-enabled, $defaultMainColor); +$backgroundColorEnabled: var(--tb-widget-button-background-color-enabled, $defaultBackgroundColor); +$boxShadowColorEnabled: var(--tb-widget-button-box-shadow-color-enabled, $defaultBoxShadowColor); + +$mainColorHovered: var(--tb-widget-button-main-color-hovered, $defaultMainColor); +$backgroundColorHovered: var(--tb-widget-button-background-color-hovered, $defaultBackgroundColor); +$boxShadowColorHovered: var(--tb-widget-button-box-shadow-color-hovered, $defaultBoxShadowColor); +$mainColorHoveredFilled: var(--tb-widget-button-main-color-hovered-filled, #3347db); // main.darken(6) + +$mainColorPressed: var(--tb-widget-button-main-color-pressed, $defaultMainColor); +$backgroundColorPressed: var(--tb-widget-button-background-color-pressed, $defaultBackgroundColor); +$boxShadowColorPressed: var(--tb-widget-button-box-shadow-color-pressed, $defaultBoxShadowColor); +$mainColorPressedFilled: var(--tb-widget-button-main-color-pressed-filled, #273cd9); // main.darken(12) +$mainColorPressedRipple: var(--tb-widget-button-main-color-pressed-ripple, rgba(63, 82, 221, 0.1)); // Alpha(Main, 0.1) +$mainColorPressedRippleFilled: var(--tb-widget-button-main-color-pressed-ripple-filled, #2439cd); // main.darken(18) + +$mainColorActivated: var(--tb-widget-button-main-color-activated, $defaultMainColor); +$backgroundColorActivated: var(--tb-widget-button-background-color-activated, $defaultBackgroundColor); +$boxShadowColorActivated: var(--tb-widget-button-box-shadow-color-activated, $defaultBoxShadowColor); +$mainColorActivatedFilled: var(--tb-widget-button-main-color-activated-filled, #273cd9); // main.darken(12) + +$mainColorDisabled: var(--tb-widget-button-main-color-disabled, $defaultMainColorDisabled); +$backgroundColorDisabled: var(--tb-widget-button-background-color-disabled, $defaultBackgroundColorDisabled); +$boxShadowColorDisabled: var(--tb-widget-button-box-shadow-color-disabled, $defaultBoxShadowColor); + + +@mixin _tb-widget-button-styles($main, $background, $boxShadow) { + color: $main; + background-color: $background; + box-shadow: 0 4px 8px 0 $boxShadow; + &.tb-outlined { + border: 1px solid $main; + } + &.tb-filled { + color: $background; + background-color: $main; + } + &.tb-underlined { + border-bottom: 2px solid $main; + } + &.tb-basic { + background-color: transparent; + } +} + + +.mat-mdc-button.mat-mdc-button-base.tb-widget-button { + width: 100%; + height: 100%; + min-width: 0; + padding: 8px 12px; + &.tb-icon-only { + padding: 8px; + } + .mdc-button__label { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + .tb-widget-button-content { + width: 100%; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: center; + align-items: center; + .mat-icon { + margin: 0; + } + span.tb-widget-button-label { + line-height: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .mat-mdc-button-persistent-ripple::before { + opacity: 0; + } + + @include _tb-widget-button-styles($mainColorEnabled, $backgroundColorEnabled, $boxShadowColorEnabled); + + &:not(:disabled):not(.tb-disabled-state) { + &:hover, &.tb-hover-state { + &:not(:active):not(.tb-active-state) { + &:not(.tb-filled) { + .mat-mdc-button-persistent-ripple::before { + opacity: 0.04; + background-color: $mainColorHovered; + } + } + &.tb-filled { + .mat-mdc-button-persistent-ripple::before { + opacity: 1; + background-color: $mainColorHoveredFilled; + } + } + @include _tb-widget-button-styles($mainColorHovered, $backgroundColorHovered, $boxShadowColorHovered); + } + } + &.tb-pressed-state { + &:not(.tb-filled) { + .mat-mdc-button-ripple { + background-color: $mainColorPressedRipple; + } + } + &.tb-filled { + .mat-mdc-button-ripple { + background-color: $mainColorPressedRippleFilled; + } + } + } + &.tb-pressed { + &:not(.tb-filled) { + .mat-ripple-element { + background-color: $mainColorPressedRipple; + } + } + &.tb-filled { + .mat-ripple-element { + background-color: $mainColorPressedRippleFilled; + } + } + } + &.tb-pressed, &.tb-pressed-state { + &:not(.tb-filled) { + .mat-mdc-button-persistent-ripple::before { + opacity: 0.12; + background-color: $mainColorPressed; + } + } + &.tb-filled { + .mat-mdc-button-persistent-ripple::before { + opacity: 1; + background-color: $mainColorPressedFilled; + } + } + @include _tb-widget-button-styles($mainColorPressed, $backgroundColorPressed, $boxShadowColorPressed); + } + &:active, &.tb-active-state { + &:not(.tb-pressed):not(.tb-pressed-state) { + &:not(.tb-filled) { + .mat-mdc-button-persistent-ripple::before { + opacity: 0.12; + background-color: $mainColorActivated; + } + } + &.tb-filled { + .mat-mdc-button-persistent-ripple::before { + opacity: 1; + background-color: $mainColorActivatedFilled; + } + } + @include _tb-widget-button-styles($mainColorActivated, $backgroundColorActivated, $boxShadowColorActivated); + } + } + } + + &:disabled, &.tb-disabled-state { + &:not(.tb-filled) { + @include _tb-widget-button-styles($mainColorDisabled, $backgroundColorDisabled, $boxShadowColorDisabled); + } + &.tb-filled { + @include _tb-widget-button-styles($backgroundColorDisabled, $mainColorDisabled, $boxShadowColorDisabled); + } + } +} diff --git a/ui-ngx/src/app/shared/components/button/widget-button.component.ts b/ui-ngx/src/app/shared/components/button/widget-button.component.ts new file mode 100644 index 0000000000..db6584d96f --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/widget-button.component.ts @@ -0,0 +1,216 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + Renderer2, + SimpleChanges, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { + generateWidgetButtonAppearanceCss, + widgetButtonDefaultAppearance +} from '@shared/components/button/widget-button.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { ComponentStyle, iconStyle, validateCssSize } from '@shared/models/widget-settings.models'; +import { UtilsService } from '@core/services/utils.service'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { Observable, of } from 'rxjs'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; + +const initialButtonHeight = 60; +const horizontalLayoutPadding = 24; +const verticalLayoutPadding = 16; + +@Component({ + selector: 'tb-widget-button', + templateUrl: './widget-button.component.html', + styleUrls: ['./widget-button.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class WidgetButtonComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges { + + @ViewChild('widgetButton', {read: ElementRef}) + widgetButton: ElementRef; + + @ViewChild('widgetButtonContent', {static: false}) + widgetButtonContent: ElementRef; + + @Input() + appearance = widgetButtonDefaultAppearance; + + @Input() + borderRadius = '4px'; + + @Input() + autoScale: boolean; + + @Input() + @coerceBoolean() + disabled = false; + + @Input() + @coerceBoolean() + activated = false; + + @Input() + @coerceBoolean() + hovered = false; + + @Input() + @coerceBoolean() + pressed = false; + + @Input() + @coerceBoolean() + disableEvents = false; + + @Input() + ctx: WidgetContext; + + @Output() + clicked = new EventEmitter(); + + label$: Observable; + + iconStyle: ComponentStyle = {}; + + computedBorderRadius: string; + + mousePressed = false; + + private buttonResize$: ResizeObserver; + + private appearanceCssClass: string; + + constructor(private renderer: Renderer2, + private elementRef: ElementRef, + private utils: UtilsService) {} + + ngOnInit(): void { + this.updateAppearance(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange) { + if (propName === 'appearance') { + this.updateAppearance(); + } else if (propName === 'borderRadius') { + this.updateBorderRadius(); + } else if (propName === 'autoScale') { + this.updateAutoScale(); + } + } + } + } + + ngAfterViewInit(): void { + this.updateAutoScale(); + } + + ngOnDestroy(): void { + if (this.buttonResize$) { + this.buttonResize$.disconnect(); + } + this.clearAppearanceCss(); + } + + public validateSize() { + if (this.appearance.autoScale && this.widgetButton.nativeElement) { + this.onResize(); + } + } + + private updateAppearance(): void { + this.clearAppearanceCss(); + if (this.appearance.showIcon) { + this.iconStyle = iconStyle(this.appearance.iconSize, this.appearance.iconSizeUnit); + } + if (this.appearance.showLabel) { + this.label$ = this.ctx ? this.ctx.registerLabelPattern(this.appearance.label, this.label$) : of(this.appearance.label); + } + this.updateBorderRadius(); + const appearanceCss = generateWidgetButtonAppearanceCss(this.appearance); + this.appearanceCssClass = this.utils.applyCssToElement(this.renderer, this.elementRef.nativeElement, + 'tb-widget-button', appearanceCss); + this.updateAutoScale(); + } + + private updateBorderRadius(): void { + const validatedBorderRadius = validateCssSize(this.appearance.borderRadius); + if (validatedBorderRadius) { + this.computedBorderRadius = validatedBorderRadius; + } else { + this.computedBorderRadius = this.borderRadius; + } + } + + private clearAppearanceCss(): void { + if (this.appearanceCssClass) { + this.utils.clearCssElement(this.renderer, this.appearanceCssClass, this.elementRef?.nativeElement); + this.appearanceCssClass = null; + } + } + + private updateAutoScale() { + if (this.buttonResize$) { + this.buttonResize$.disconnect(); + } + if (this.widgetButton && this.widgetButtonContent) { + const autoScale = isDefinedAndNotNull(this.autoScale) ? this.autoScale : this.appearance.autoScale; + if (autoScale) { + this.buttonResize$ = new ResizeObserver(() => { + this.onResize(); + }); + this.buttonResize$.observe(this.widgetButton.nativeElement); + this.onResize(); + } else { + this.renderer.setStyle(this.widgetButtonContent.nativeElement, 'transform', 'none'); + this.renderer.setStyle(this.widgetButtonContent.nativeElement, 'width', '100%'); + } + } + } + + private onResize() { + const height = this.widgetButton.nativeElement.getBoundingClientRect().height; + const buttonScale = height / initialButtonHeight; + const paddingScale = Math.min(buttonScale, 1); + const buttonWidth = this.widgetButton.nativeElement.getBoundingClientRect().width - (horizontalLayoutPadding * paddingScale); + const buttonHeight = this.widgetButton.nativeElement.getBoundingClientRect().height - (verticalLayoutPadding * paddingScale); + this.renderer.setStyle(this.widgetButtonContent.nativeElement, 'transform', `scale(1)`); + this.renderer.setStyle(this.widgetButtonContent.nativeElement, 'width', 'auto'); + const contentWidth = this.widgetButtonContent.nativeElement.getBoundingClientRect().width; + const contentHeight = this.widgetButtonContent.nativeElement.getBoundingClientRect().height; + const maxScale = Math.max(1, buttonScale); + const scale = Math.min(Math.min(buttonWidth / contentWidth, buttonHeight / contentHeight), maxScale); + const targetWidth = buttonWidth / scale; + this.renderer.setStyle(this.widgetButtonContent.nativeElement, 'width', targetWidth + 'px'); + this.renderer.setStyle(this.widgetButtonContent.nativeElement, 'transform', `scale(${scale})`); + } + +} diff --git a/ui-ngx/src/app/shared/components/button/widget-button.models.ts b/ui-ngx/src/app/shared/components/button/widget-button.models.ts new file mode 100644 index 0000000000..b81cd43f5d --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/widget-button.models.ts @@ -0,0 +1,272 @@ +/// +/// 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 { cssUnit } from '@shared/models/widget-settings.models'; +import tinycolor from 'tinycolor2'; + +const defaultMainColor = '#3F52DD'; +const defaultBackgroundColor = '#FFFFFF'; + +const hoveredFilledDarkenAmount = 6; +const pressedFilledDarkenAmount = 12; +const activatedFilledDarkenAmount = 12; +const pressedRippleFilledDarkenAmount = 18; + +export const defaultMainColorDisabled = 'rgba(0, 0, 0, 0.38)'; +export const defaultBackgroundColorDisabled = 'rgba(0, 0, 0, 0.03)'; + +const defaultBoxShadowColor = 'rgba(0, 0, 0, 0.08)'; +const defaultDisabledBoxShadowColor = 'rgba(0, 0, 0, 0)'; + +export enum WidgetButtonType { + outlined = 'outlined', + filled = 'filled', + underlined = 'underlined', + basic = 'basic' +} + +export const widgetButtonTypes = Object.keys(WidgetButtonType) as WidgetButtonType[]; + +export const widgetButtonTypeTranslations = new Map( + [ + [WidgetButtonType.outlined, 'widgets.button.outlined'], + [WidgetButtonType.filled, 'widgets.button.filled'], + [WidgetButtonType.underlined, 'widgets.button.underlined'], + [WidgetButtonType.basic, 'widgets.button.basic'] + ] +); + +export const widgetButtonTypeImages = new Map( + [ + [WidgetButtonType.outlined, 'assets/widget/button/outlined.svg'], + [WidgetButtonType.filled, 'assets/widget/button/filled.svg'], + [WidgetButtonType.underlined, 'assets/widget/button/underlined.svg'], + [WidgetButtonType.basic, 'assets/widget/button/basic.svg'] + ] +); + +export enum WidgetButtonState { + enabled = 'enabled', + hovered = 'hovered', + pressed = 'pressed', + activated = 'activated', + disabled = 'disabled' +} + +export const widgetButtonStates = Object.keys(WidgetButtonState) as WidgetButtonState[]; + +export const widgetButtonStatesTranslations = new Map( + [ + [WidgetButtonState.enabled, 'widgets.button-state.enabled'], + [WidgetButtonState.hovered, 'widgets.button-state.hovered'], + [WidgetButtonState.pressed, 'widgets.button-state.pressed'], + [WidgetButtonState.activated, 'widgets.button-state.activated'], + [WidgetButtonState.disabled, 'widgets.button-state.disabled'] + ] +); + +export interface WidgetButtonCustomStyle { + overrideMainColor?: boolean; + mainColor?: string; + overrideBackgroundColor?: boolean; + backgroundColor?: string; + overrideDropShadow?: boolean; + dropShadow?: boolean; +} + +export type WidgetButtonCustomStyles = Record; + +export interface WidgetButtonAppearance { + type: WidgetButtonType; + autoScale: boolean; + showLabel: boolean; + label: string; + showIcon: boolean; + icon: string; + iconSize: number; + iconSizeUnit: cssUnit; + borderRadius?: string; + mainColor: string; + backgroundColor: string; + customStyle: WidgetButtonCustomStyles; +} + +export const widgetButtonDefaultAppearance: WidgetButtonAppearance = { + type: WidgetButtonType.outlined, + autoScale: true, + showLabel: true, + label: 'Button', + showIcon: true, + icon: 'home', + iconSize: 24, + iconSizeUnit: 'px', + mainColor: defaultMainColor, + backgroundColor: defaultBackgroundColor, + customStyle: { + enabled: null, + hovered: null, + pressed: null, + activated: null, + disabled: null + } +}; + +const mainColorVarPrefix = '--tb-widget-button-main-color-'; +const backgroundColorVarPrefix = '--tb-widget-button-background-color-'; +const boxShadowColorVarPrefix = '--tb-widget-button-box-shadow-color-'; + +abstract class ButtonStateCssGenerator { + + constructor() {} + + public generateStateCss(appearance: WidgetButtonAppearance): string { + let mainColor = this.getMainColor(appearance); + let backgroundColor = this.getBackgroundColor(appearance); + const shadowEnabledByDefault = appearance.type !== WidgetButtonType.basic; + let shadowColor = shadowEnabledByDefault ? defaultBoxShadowColor : defaultDisabledBoxShadowColor; + const stateCustomStyle = appearance.customStyle[this.state]; + if (stateCustomStyle?.overrideMainColor && stateCustomStyle?.mainColor) { + mainColor = stateCustomStyle.mainColor; + } + if (stateCustomStyle?.overrideBackgroundColor && stateCustomStyle?.backgroundColor) { + backgroundColor = stateCustomStyle.backgroundColor; + } + if (stateCustomStyle?.overrideDropShadow) { + shadowColor = !!stateCustomStyle.dropShadow ? defaultBoxShadowColor : defaultDisabledBoxShadowColor; + } + + let css = `${mainColorVarPrefix}${this.state}: ${mainColor};\n`+ + `${backgroundColorVarPrefix}${this.state}: ${backgroundColor};\n`+ + `${boxShadowColorVarPrefix}${this.state}: ${shadowColor};`; + const additionalCss = this.generateAdditionalStateCss(mainColor, backgroundColor); + if (additionalCss) { + css += `\n${additionalCss}`; + } + return css; + } + + protected abstract get state(): WidgetButtonState; + + protected getMainColor(appearance: WidgetButtonAppearance): string { + return appearance.mainColor || defaultMainColor; + } + + protected getBackgroundColor(appearance: WidgetButtonAppearance): string { + return appearance.backgroundColor || defaultBackgroundColor; + } + + protected generateAdditionalStateCss(_mainColor: string, _backgroundColor: string): string { + return null; + } +} + +class EnabledButtonStateCssGenerator extends ButtonStateCssGenerator { + + protected get state(): WidgetButtonState { + return WidgetButtonState.enabled; + } +} + +class HoveredButtonStateCssGenerator extends ButtonStateCssGenerator { + + protected get state(): WidgetButtonState { + return WidgetButtonState.hovered; + } + + protected generateAdditionalStateCss(mainColor: string): string { + const mainColorHoveredFilled = darkenColor(mainColor, hoveredFilledDarkenAmount); + return `--tb-widget-button-main-color-hovered-filled: ${mainColorHoveredFilled};`; + } +} + +class PressedButtonStateCssGenerator extends ButtonStateCssGenerator { + + protected get state(): WidgetButtonState { + return WidgetButtonState.pressed; + } + + protected generateAdditionalStateCss(mainColor: string): string { + const mainColorPressedFilled = darkenColor(mainColor, pressedFilledDarkenAmount); + const mainColorInstance = tinycolor(mainColor); + const mainColorPressedRipple = mainColorInstance.setAlpha(mainColorInstance.getAlpha() * 0.1).toRgbString(); + const mainColorPressedRippleFilled = darkenColor(mainColor, pressedRippleFilledDarkenAmount); + return `--tb-widget-button-main-color-pressed-filled: ${mainColorPressedFilled};\n`+ + `--tb-widget-button-main-color-pressed-ripple: ${mainColorPressedRipple};\n`+ + `--tb-widget-button-main-color-pressed-ripple-filled: ${mainColorPressedRippleFilled};`; + } +} + +class ActivatedButtonStateCssGenerator extends ButtonStateCssGenerator { + + protected get state(): WidgetButtonState { + return WidgetButtonState.activated; + } + + protected generateAdditionalStateCss(mainColor: string): string { + const mainColorActivatedFilled = darkenColor(mainColor, activatedFilledDarkenAmount); + return `--tb-widget-button-main-color-activated-filled: ${mainColorActivatedFilled};`; + } +} + +class DisabledButtonStateCssGenerator extends ButtonStateCssGenerator { + + protected get state(): WidgetButtonState { + return WidgetButtonState.disabled; + } + + protected getMainColor(): string { + return defaultMainColorDisabled; + } + + protected getBackgroundColor(): string { + return defaultBackgroundColorDisabled; + } +} + +const buttonStateCssGeneratorsMap = new Map( + [ + [WidgetButtonState.enabled, new EnabledButtonStateCssGenerator()], + [WidgetButtonState.hovered, new HoveredButtonStateCssGenerator()], + [WidgetButtonState.pressed, new PressedButtonStateCssGenerator()], + [WidgetButtonState.activated, new ActivatedButtonStateCssGenerator()], + [WidgetButtonState.disabled, new DisabledButtonStateCssGenerator()] + ] +); + +const widgetButtonCssSelector = '.mat-mdc-button.mat-mdc-button-base.tb-widget-button'; + +export const generateWidgetButtonAppearanceCss = (appearance: WidgetButtonAppearance): string => { + let statesCss = ''; + for (const state of widgetButtonStates) { + const generator = buttonStateCssGeneratorsMap.get(state); + statesCss += `\n${generator.generateStateCss(appearance)}`; + } + return `${widgetButtonCssSelector} {\n`+ + `${statesCss}\n`+ + `}`; +}; + +const darkenColor = (inputColor: string, amount: number): string => { + const input = tinycolor(inputColor); + const brightness = input.getBrightness() / 255; + let ratio: number; + if (brightness >= 0.4 && brightness <= 0.5) { + ratio = brightness + 0.2; + } else { + ratio = Math.max(0.1, Math.log10(brightness * 8)); + } + return input.darken(ratio * amount).toRgbString(); +}; diff --git a/ui-ngx/src/app/shared/components/cheatsheet.component.ts b/ui-ngx/src/app/shared/components/cheatsheet.component.ts index e90f72af87..958f4186a3 100644 --- a/ui-ngx/src/app/shared/components/cheatsheet.component.ts +++ b/ui-ngx/src/app/shared/components/cheatsheet.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/shared/components/circular-progress.directive.ts b/ui-ngx/src/app/shared/components/circular-progress.directive.ts index cc1f7e7e2e..52edc0a483 100644 --- a/ui-ngx/src/app/shared/components/circular-progress.directive.ts +++ b/ui-ngx/src/app/shared/components/circular-progress.directive.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/shared/components/color-input.component.html b/ui-ngx/src/app/shared/components/color-input.component.html index cd40dde783..29df75d8d6 100644 --- a/ui-ngx/src/app/shared/components/color-input.component.html +++ b/ui-ngx/src/app/shared/components/color-input.component.html @@ -1,6 +1,6 @@ - - {{ label }} + + {{ label }} + [matAutocomplete]="dashboardAutocomplete" + [fxShow]="!useDashboardLink || !disabled || !selectDashboardFormGroup.get('dashboard').value"> + + {{ displayDashboardFn(selectDashboardFormGroup.get('dashboard').value) }} + + + warning + - + - + diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts index b1ff5be055..2cdaf2d13c 100644 --- a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.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,7 +15,13 @@ /// import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; import { Observable, of } from 'rxjs'; import { PageLink } from '@shared/models/page/page-link'; import { Direction } from '@shared/models/page/sort-order'; @@ -28,8 +34,11 @@ import { AppState } from '@app/core/core.state'; import { getCurrentAuthUser } from '@app/core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; import { TranslateService } from '@ngx-translate/core'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { getEntityDetailsPageURL } from '@core/utils'; +import { EntityType } from '@shared/models/entity-type.models'; +import { AuthUser } from '@shared/models/user.model'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-dashboard-autocomplete', @@ -79,14 +88,16 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI @Input() subscriptSizing: SubscriptSizing = 'fixed'; - private requiredValue: boolean; - get required(): boolean { - return this.requiredValue; - } @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } + @coerceBoolean() + inlineField: boolean; + + @Input() + requiredText: string; + + @Input() + @coerceBoolean() + required: boolean; @Input() disabled: boolean; @@ -97,14 +108,25 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI searchText = ''; - private propagateChange = (v: any) => { }; + dashboardURL: string; + + useDashboardLink = true; + + private authUser: AuthUser; + + private propagateChange = (_v: any) => { }; constructor(private store: Store, public translate: TranslateService, private dashboardService: DashboardService, private fb: UntypedFormBuilder) { + this.authUser = getCurrentAuthUser(this.store); + if (this.authUser.authority === Authority.SYS_ADMIN) { + this.useDashboardLink = false; + } + this.selectDashboardFormGroup = this.fb.group({ - dashboard: [null] + dashboard: [null, this.required ? [Validators.required] : []] }); } @@ -112,7 +134,7 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } ngOnInit() { @@ -120,7 +142,7 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI .pipe( debounceTime(150), tap(value => { - let modelValue; + let modelValue: string | DashboardInfo; if (typeof value === 'string' || !value) { modelValue = null; } else { @@ -173,6 +195,9 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI this.dashboardService.getDashboardInfo(value).subscribe( (dashboard) => { this.modelValue = this.useIdValue ? dashboard.id.id : dashboard; + if (this.useDashboardLink) { + this.dashboardURL = getEntityDetailsPageURL(this.modelValue as string, EntityType.DASHBOARD); + } this.selectDashboardFormGroup.get('dashboard').patchValue(dashboard, {emitEvent: false}); } ); @@ -201,22 +226,19 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI fetchDashboards(searchText?: string): Observable> { this.searchText = searchText; - const pageLink = new PageLink(10, 0, searchText, { + const pageLink = new PageLink(25, 0, searchText, { property: 'title', direction: Direction.ASC }); return this.getDashboards(pageLink).pipe( catchError(() => of(emptyPageData())), - map(pageData => { - return pageData.data; - }) + map(pageData => pageData.data) ); } getDashboards(pageLink: PageLink): Observable> { let dashboardsObservable: Observable>; - const authUser = getCurrentAuthUser(this.store); - if (this.dashboardsScope === 'customer' || authUser.authority === Authority.CUSTOMER_USER) { + if (this.dashboardsScope === 'customer' || this.authUser.authority === Authority.CUSTOMER_USER) { if (this.customerId) { dashboardsObservable = this.dashboardService.getCustomerDashboards(this.customerId, pageLink, {ignoreLoading: true}); @@ -224,7 +246,7 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI dashboardsObservable = of(emptyPageData()); } } else { - if (authUser.authority === Authority.SYS_ADMIN) { + if (this.authUser.authority === Authority.SYS_ADMIN) { if (this.tenantId) { dashboardsObservable = this.dashboardService.getTenantDashboardsByTenantId(this.tenantId, pageLink, {ignoreLoading: true}); diff --git a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html index 4a36ff8e2d..a564568379 100644 --- a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html +++ b/ui-ngx/src/app/shared/components/dashboard-select-panel.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 93% 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 8787e24eb5..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; 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 e0eea202ff..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,7 +49,7 @@ 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; @@ -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..045f51b808 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/embed-image-dialog.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. + */ +:host { + .mat-mdc-dialog-content { + display: flex; + width: 780px; + 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..40e7d2ec89 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts @@ -0,0 +1,166 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit } from '@angular/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 { ImageResource, 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.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; + let url; + if (result.base64) { + url = result.base64; + } else { + url = this.image.link; + } + this.imagePreviewData = {url}; + } + }); + } + +} 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/shared/components/image/images-in-use-dialog.component.scss b/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss new file mode 100644 index 0000000000..682f3a1d0c --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss @@ -0,0 +1,40 @@ +/** + * 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 { + .tb-images-in-use-content { + display: flex; + flex-direction: column; + gap: 24px; + &.multiple { + gap: 16px; + } + .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..b61ef03eef --- /dev/null +++ b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.ts @@ -0,0 +1,119 @@ +/// +/// 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 { ImageResource, ImageResourceInfo, imageResourceType } from '@shared/models/resource.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { forkJoin } from 'rxjs'; +import { blobToBase64 } from '@core/utils'; + +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; + forkJoin([ + this.imageService.uploadImage(file, title), + blobToBase64(file) + ]).subscribe(([imageInfo, base64]) => { + this.dialogRef.close(Object.assign(imageInfo, {base64})); + }); + } else { + const image = this.data.image; + forkJoin([ + this.imageService.updateImage(imageResourceType(image), image.resourceKey, file), + blobToBase64(file) + ]).subscribe(([imageInfo, base64]) => { + this.dialogRef.close(Object.assign(imageInfo, {base64})); + }); + } + } +} 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..66bced8adf 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 @@ -
- - + + +
+ +
+ +
+
+ +
+
diff --git a/ui-ngx/src/app/shared/components/snack-bar-component.scss b/ui-ngx/src/app/shared/components/snack-bar-component.scss index b2db177e06..d86b740fc3 100644 --- a/ui-ngx/src/app/shared/components/snack-bar-component.scss +++ b/ui-ngx/src/app/shared/components/snack-bar-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. @@ -71,4 +71,42 @@ background: #008000; } } + + .tb-modern-toast { + display: flex; + flex-direction: column; + z-index: 1; + &-panel { + display: flex; + padding: 4px 4px 4px 12px; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 4px; + box-shadow: -2px 2px 4px 0px rgba(0,0,0,0.2); + &.info-toast { + background: #e6e6e6; + color: rgba(50, 50, 50, 1); + } + &.warn-toast { + background: #fff3eb; + color: rgba(220, 109, 27, 1); + + } + &.error-toast { + background-color: #fff2f3; + color: rgba(209, 39, 48, 1); + } + &.success-toast { + background: #ebfcec; + color: rgba(0, 128, 0, 1); + } + } + .toast-text { + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + } } diff --git a/ui-ngx/src/app/shared/components/socialshare-panel.component.html b/ui-ngx/src/app/shared/components/socialshare-panel.component.html index 0cdbd32cb4..affe63314c 100644 --- a/ui-ngx/src/app/shared/components/socialshare-panel.component.html +++ b/ui-ngx/src/app/shared/components/socialshare-panel.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 @@ -
- - value.type - +
+ + - - {{ valueTypes.get(valueType).name | translate }} +
+ + {{ valueTypes.get(valueType).name | translate }} +
- + {{ valueTypes.get(valueTypeEnum[valueType]).name | translate }}
- - value.string-value - - - {{ (requiredText ? requiredText : 'value.string-value-required') | translate }} - + + + + warning + - - value.integer-value - - - {{ (requiredText ? requiredText : 'value.integer-value-required') | translate }} - - - {{ 'value.invalid-integer-value' | translate }} - + + + + warning + - - value.double-value - - - {{ (requiredText ? requiredText : 'value.double-value-required') | translate }} - + + + + warning + -
- - {{ (modelValue ? 'value.true' : 'value.false') | translate }} - -
-
- - value.json-value + + {{ trueLabel | translate }} + {{ falseLabel | translate }} + +
+ - - - {{ (requiredText ? requiredText : 'value.json-value-required') | translate }} - - - {{ 'value.json-value-invalid' | translate }} - + [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()" placeholder="{{ 'value.json-value' | translate }}*"/> + + warning + +
diff --git a/ui-ngx/src/app/shared/components/value-input.component.scss b/ui-ngx/src/app/shared/components/value-input.component.scss index 788663c7a9..88355f4459 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.scss +++ b/ui-ngx/src/app/shared/components/value-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. @@ -13,14 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +:host { + .tb-boolean-input { + min-width: 210px; + } +} + :host ::ng-deep { .mat-mdc-form-field.tb-value-type { + &.row { + width: 140px; + min-width: 140px; + } + &.column { + flex: 1; + width: auto; + } + .mat-mdc-form-field-infix { + max-height: 40px; + } mat-select-trigger { - .mat-icon { - vertical-align: bottom; - margin-right: 16px; - svg { - vertical-align: initial; + .tb-value-type-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 10px; + .mat-icon { + color: rgba(0, 0, 0, 0.38); + vertical-align: bottom; } } } diff --git a/ui-ngx/src/app/shared/components/value-input.component.ts b/ui-ngx/src/app/shared/components/value-input.component.ts index 6d57b1ddd2..0479b2f0df 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.ts +++ b/ui-ngx/src/app/shared/components/value-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. @@ -14,15 +14,34 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + forwardRef, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewChild +} from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgForm } from '@angular/forms'; -import { ValueType, valueTypesMap } from '@shared/models/constants'; +import { resolveBreakpoint, ValueType, valueTypesMap } from '@shared/models/constants'; import { isObject } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { JsonObjectEditDialogComponent, JsonObjectEditDialogData } from '@shared/components/dialog/json-object-edit-dialog.component'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { Subscription } from 'rxjs'; + +type Layout = 'column' | 'row'; + +export interface ValueInputLayout { + layout: Layout; + breakpoints?: {[breakpoint: string]: Layout}; +} @Component({ selector: 'tb-value-input', @@ -36,33 +55,85 @@ import { } ] }) -export class ValueInputComponent implements OnInit, ControlValueAccessor { +export class ValueInputComponent implements OnInit, OnDestroy, OnChanges, ControlValueAccessor { + + @Input() + disabled: boolean; + + @Input() + requiredText: string; + + @Input() + valueType: ValueType; - @Input() disabled: boolean; + @Input() + trueLabel = 'value.true'; - @Input() requiredText: string; + @Input() + falseLabel = 'value.false'; + + @Input() + layout: ValueInputLayout | Layout = 'row'; @ViewChild('inputForm', {static: true}) inputForm: NgForm; modelValue: any; - valueType: ValueType; - public valueTypeEnum = ValueType; valueTypeKeys = Object.keys(ValueType); valueTypes = valueTypesMap; + showValueType = true; + + computedLayout: Layout; + private propagateChange = null; + private _subscription: Subscription; + constructor( + private breakpointObserver: BreakpointObserver, + private cd: ChangeDetectorRef, public dialog: MatDialog, ) { } ngOnInit(): void { + this._subscription = new Subscription(); + this.showValueType = !this.valueType; + this.computedLayout = this._computeLayout(); + if (typeof this.layout === 'object' && this.layout.breakpoints) { + const breakpoints = Object.keys(this.layout.breakpoints); + this._subscription.add(this.breakpointObserver.observe(breakpoints.map(breakpoint => resolveBreakpoint(breakpoint))).subscribe( + () => { + this.computedLayout = this._computeLayout(); + } + )); + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange) { + if (propName === 'valueType') { + this.showValueType = !this.valueType; + if (this.valueType) { + this.updateModelToValueType(); + } else { + this.detectValueType(); + } + this.cd.markForCheck(); + } + } + } + } + + ngOnDestroy() { + this._subscription.unsubscribe(); } openEditJSONDialog($event: Event) { @@ -100,18 +171,13 @@ export class ValueInputComponent implements OnInit, ControlValueAccessor { writeValue(value: any): void { this.modelValue = value; - if (this.modelValue === true || this.modelValue === false) { - this.valueType = ValueType.BOOLEAN; - } else if (typeof this.modelValue === 'number') { - if (this.modelValue.toString().indexOf('.') === -1) { - this.valueType = ValueType.INTEGER; - } else { - this.valueType = ValueType.DOUBLE; - } - } else if (isObject(this.modelValue)) { - this.valueType = ValueType.JSON; + if (this.showValueType) { + this.detectValueType(); } else { - this.valueType = ValueType.STRING; + setTimeout(() => { + this.updateModelToValueType(); + this.cd.markForCheck(); + }, 0); } } @@ -140,4 +206,55 @@ export class ValueInputComponent implements OnInit, ControlValueAccessor { this.updateView(); } + private detectValueType() { + if (this.modelValue === true || this.modelValue === false) { + this.valueType = ValueType.BOOLEAN; + } else if (typeof this.modelValue === 'number') { + if (this.modelValue.toString().indexOf('.') === -1) { + this.valueType = ValueType.INTEGER; + } else { + this.valueType = ValueType.DOUBLE; + } + } else if (isObject(this.modelValue)) { + this.valueType = ValueType.JSON; + } else { + this.valueType = ValueType.STRING; + } + } + + private updateModelToValueType() { + if (this.valueType === ValueType.BOOLEAN && typeof this.modelValue !== 'boolean') { + this.modelValue = !!this.modelValue; + this.updateView(); + } else if (this.valueType === ValueType.STRING && typeof this.modelValue !== 'string') { + this.modelValue = null; + this.updateView(); + } else if ([ValueType.DOUBLE, ValueType.INTEGER].includes(this.valueType) && typeof this.modelValue !== 'number') { + this.modelValue = null; + this.updateView(); + } else if (this.valueType === ValueType.JSON && typeof this.modelValue !== 'object') { + this.modelValue = {}; + this.inputForm.form.get('value').patchValue({}); + this.updateView(); + } + } + + private _computeLayout(): Layout { + if (typeof this.layout !== 'object') { + return this.layout; + } else { + let layout = this.layout.layout; + if (this.layout.breakpoints) { + for (const breakpoint of Object.keys(this.layout.breakpoints)) { + const breakpointValue = resolveBreakpoint(breakpoint); + if (this.breakpointObserver.isMatched(breakpointValue)) { + layout = this.layout.breakpoints[breakpoint]; + break; + } + } + } + return layout; + } + } + } diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html index 5478253f72..486f163533 100644 --- a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html @@ -1,6 +1,6 @@