diff --git a/application/src/main/data/json/system/widget_bundles/control_widgets.json b/application/src/main/data/json/system/widget_bundles/control_widgets.json index fb7c6e1ee2..5dce630e64 100644 --- a/application/src/main/data/json/system/widget_bundles/control_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/control_widgets.json @@ -2,13 +2,13 @@ "widgetsBundle": { "alias": "control_widgets", "title": "Control widgets", - "image": "data:image/png;base64,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", + "image": "tb-image:Y29udHJvbF93aWRnZXRzX3N5c3RlbV9idW5kbGVfaW1hZ2UucG5n:IkNvbnRyb2wgd2lkZ2V0cyIgc3lzdGVtIGJ1bmRsZSBpbWFnZQ==;data:image/png;base64,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", "description": "Various interactive widgets to control the behavior and state of the IoT devices.", "order": 8000, - "externalId": null, "name": "Control widgets" }, "widgetTypeFqns": [ + "single_switch", "control_widgets.switch_control", "control_widgets.slide_toggle_control", "control_widgets.round_switch", diff --git a/application/src/main/data/json/system/widget_types/single_switch.json b/application/src/main/data/json/system/widget_types/single_switch.json new file mode 100644 index 0000000000..3e5edd6f82 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/single_switch.json @@ -0,0 +1,35 @@ +{ + "fqn": "single_switch", + "name": "Single Switch", + "deprecated": false, + "image": "tb-image:c2luZ2xlLXN3aXRjaC5zdmc=:IlNpbmdsZSBTd2l0Y2giIHN5c3RlbSB3aWRnZXQgaW1hZ2U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAxIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMSAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8zNjM5XzE1NTY3NCkiPgo8cmVjdCB4PSIwLjUiIHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiByeD0iNCIgZmlsbD0id2hpdGUiLz4KPHJlY3QgeD0iMSIgeT0iMC41IiB3aWR0aD0iMTk5IiBoZWlnaHQ9IjE1OSIgcng9IjMuNSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiLz4KPHJlY3QgeD0iNTUiIHk9IjcwIiB3aWR0aD0iMzguMDk1MiIgaGVpZ2h0PSIxOS45NTQ3IiByeD0iOS45NzczMyIgZmlsbD0iIzU0NjlGRiIvPgo8Y2lyY2xlIGN4PSI4My4xMTY0IiBjeT0iNzkuOTc3MiIgcj0iOC4xNjMyNyIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTEwNi42OTQgODEuNzUzNEMxMDYuNjk0IDgxLjUzMzcgMTA2LjY2IDgxLjMzODQgMTA2LjU5MiA4MS4xNjc1QzEwNi41MjggODAuOTk2NiAxMDYuNDE0IDgwLjg0MDMgMTA2LjI0OCA4MC42OTg3QzEwNi4wODIgODAuNTU3MSAxMDUuODQ3IDgwLjQyMDQgMTA1LjU0NCA4MC4yODg2QzEwNS4yNDcgODAuMTUxOSAxMDQuODY2IDgwLjAxMjcgMTA0LjQwMiA3OS44NzExQzEwMy44OTQgNzkuNzE0OCAxMDMuNDI1IDc5LjU0MTUgMTAyLjk5NiA3OS4zNTExQzEwMi41NzEgNzkuMTU1OCAxMDIuMiA3OC45MzEyIDEwMS44ODIgNzguNjc3MkMxMDEuNTY1IDc4LjQxODUgMTAxLjMxOCA3OC4xMjMgMTAxLjE0MyA3Ny43OTFDMTAwLjk2NyA3Ny40NTQxIDEwMC44NzkgNzcuMDY1OSAxMDAuODc5IDc2LjYyNjVDMTAwLjg3OSA3Ni4xOTE5IDEwMC45NjkgNzUuNzk2NCAxMDEuMTUgNzUuNDM5OUMxMDEuMzM1IDc1LjA4MzUgMTAxLjU5NyA3NC43NzU5IDEwMS45MzQgNzQuNTE3MUMxMDIuMjc1IDc0LjI1MzQgMTAyLjY3OCA3NC4wNTA4IDEwMy4xNDIgNzMuOTA5MkMxMDMuNjA2IDczLjc2MjcgMTA0LjExOSA3My42ODk1IDEwNC42OCA3My42ODk1QzEwNS40NzEgNzMuNjg5NSAxMDYuMTUyIDczLjgzNTkgMTA2LjcyNCA3NC4xMjg5QzEwNy4zIDc0LjQyMTkgMTA3Ljc0MiA3NC44MTQ5IDEwOC4wNDkgNzUuMzA4MUMxMDguMzYyIDc1LjgwMTMgMTA4LjUxOCA3Ni4zNDU3IDEwOC41MTggNzYuOTQxNEgxMDYuNjk0QzEwNi42OTQgNzYuNTg5OCAxMDYuNjE5IDc2LjI3OTggMTA2LjQ2NyA3Ni4wMTEyQzEwNi4zMjEgNzUuNzM3OCAxMDYuMDk2IDc1LjUyMjkgMTA1Ljc5MyA3NS4zNjY3QzEwNS40OTYgNzUuMjEwNCAxMDUuMTE3IDc1LjEzMjMgMTA0LjY1OCA3NS4xMzIzQzEwNC4yMjQgNzUuMTMyMyAxMDMuODYyIDc1LjE5ODIgMTAzLjU3NCA3NS4zMzAxQzEwMy4yODYgNzUuNDYxOSAxMDMuMDcxIDc1LjY0MDEgMTAyLjkzIDc1Ljg2NDdDMTAyLjc4OCA3Ni4wODk0IDEwMi43MTcgNzYuMzQzMyAxMDIuNzE3IDc2LjYyNjVDMTAyLjcxNyA3Ni44MjY3IDEwMi43NjQgNzcuMDA5OCAxMDIuODU2IDc3LjE3NThDMTAyLjk0OSA3Ny4zMzY5IDEwMy4wOTEgNzcuNDg4MyAxMDMuMjgxIDc3LjYyOTlDMTAzLjQ3MiA3Ny43NjY2IDEwMy43MTEgNzcuODk2IDEwMy45OTkgNzguMDE4MUMxMDQuMjg3IDc4LjE0MDEgMTA0LjYyNiA3OC4yNTczIDEwNS4wMTcgNzguMzY5NkMxMDUuNjA4IDc4LjU0NTQgMTA2LjEyMyA3OC43NDA3IDEwNi41NjIgNzguOTU1NkMxMDcuMDAyIDc5LjE2NTUgMTA3LjM2OCA3OS40MDQ4IDEwNy42NjEgNzkuNjczM0MxMDcuOTU0IDc5Ljk0MTkgMTA4LjE3NCA4MC4yNDcxIDEwOC4zMiA4MC41ODg5QzEwOC40NjcgODAuOTI1OCAxMDguNTQgODEuMzA5MSAxMDguNTQgODEuNzM4OEMxMDguNTQgODIuMTg4IDEwOC40NSA4Mi41OTMzIDEwOC4yNjkgODIuOTU0NkMxMDguMDg4IDgzLjMxMSAxMDcuODMgODMuNjE2MiAxMDcuNDkzIDgzLjg3MDFDMTA3LjE2MSA4NC4xMTkxIDEwNi43NiA4NC4zMTIgMTA2LjI5MiA4NC40NDg3QzEwNS44MjggODQuNTgwNiAxMDUuMzEgODQuNjQ2NSAxMDQuNzM5IDg0LjY0NjVDMTA0LjIyNiA4NC42NDY1IDEwMy43MjEgODQuNTc4MSAxMDMuMjIzIDg0LjQ0MTRDMTAyLjcyOSA4NC4zMDQ3IDEwMi4yOCA4NC4wOTcyIDEwMS44NzUgODMuODE4OEMxMDEuNDcgODMuNTM1NiAxMDEuMTQ3IDgzLjE4NDEgMTAwLjkwOCA4Mi43NjQyQzEwMC42NjkgODIuMzM5NCAxMDAuNTQ5IDgxLjg0MzggMTAwLjU0OSA4MS4yNzczSDEwMi4zODhDMTAyLjM4OCA4MS42MjQgMTAyLjQ0NiA4MS45MTk0IDEwMi41NjMgODIuMTYzNkMxMDIuNjg2IDgyLjQwNzcgMTAyLjg1NCA4Mi42MDc5IDEwMy4wNjkgODIuNzY0MkMxMDMuMjg0IDgyLjkxNTUgMTAzLjUzMyA4My4wMjc4IDEwMy44MTYgODMuMTAxMUMxMDQuMTA0IDgzLjE3NDMgMTA0LjQxMiA4My4yMTA5IDEwNC43MzkgODMuMjEwOUMxMDUuMTY4IDgzLjIxMDkgMTA1LjUyNyA4My4xNDk5IDEwNS44MTUgODMuMDI3OEMxMDYuMTA4IDgyLjkwNTggMTA2LjMyOCA4Mi43MzQ5IDEwNi40NzUgODIuNTE1MUMxMDYuNjIxIDgyLjI5NTQgMTA2LjY5NCA4Mi4wNDE1IDEwNi42OTQgODEuNzUzNFpNMTEyLjE0NCA4Mi43NDIyTDExMy45NzUgNzYuNTc1MkgxMTUuMTAzTDExNC43OTUgNzguNDIwOUwxMTIuOTQ5IDg0LjVIMTExLjkzOEwxMTIuMTQ0IDgyLjc0MjJaTTExMS4wNjcgNzYuNTc1MkwxMTIuNDk1IDgyLjc3MTVMMTEyLjYxMiA4NC41SDExMS40ODRMMTA5LjMzOCA3Ni41NzUySDExMS4wNjdaTTExNi44MTYgODIuNjk4MkwxMTguMjAxIDc2LjU3NTJIMTE5LjkyMkwxMTcuNzgzIDg0LjVIMTE2LjY1NUwxMTYuODE2IDgyLjY5ODJaTTExNS4yOTMgNzYuNTc1MkwxMTcuMTAyIDgyLjY2ODlMMTE3LjMyOSA4NC41SDExNi4zMThMMTE0LjQ1MSA3OC40MTM2TDExNC4xNDMgNzYuNTc1MkgxMTUuMjkzWk0xMjMuMDEzIDc2LjU3NTJWODQuNUgxMjEuMjRWNzYuNTc1MkgxMjMuMDEzWk0xMjEuMTIzIDc0LjQ5NTFDMTIxLjEyMyA3NC4yMjY2IDEyMS4yMTEgNzQuMDA0NCAxMjEuMzg3IDczLjgyODZDMTIxLjU2NyA3My42NDc5IDEyMS44MTYgNzMuNTU3NiAxMjIuMTM0IDczLjU1NzZDMTIyLjQ0NiA3My41NTc2IDEyMi42OTMgNzMuNjQ3OSAxMjIuODc0IDczLjgyODZDMTIzLjA1NCA3NC4wMDQ0IDEyMy4xNDUgNzQuMjI2NiAxMjMuMTQ1IDc0LjQ5NTFDMTIzLjE0NSA3NC43NTg4IDEyMy4wNTQgNzQuOTc4NSAxMjIuODc0IDc1LjE1NDNDMTIyLjY5MyA3NS4zMzAxIDEyMi40NDYgNzUuNDE4IDEyMi4xMzQgNzUuNDE4QzEyMS44MTYgNzUuNDE4IDEyMS41NjcgNzUuMzMwMSAxMjEuMzg3IDc1LjE1NDNDMTIxLjIxMSA3NC45Nzg1IDEyMS4xMjMgNzQuNzU4OCAxMjEuMTIzIDc0LjQ5NTFaTTEyOC41NzkgNzYuNTc1MlY3Ny44NjQzSDEyNC4xMTFWNzYuNTc1MkgxMjguNTc5Wk0xMjUuNCA3NC42MzQzSDEyNy4xNjZWODIuMzEwMUMxMjcuMTY2IDgyLjU1NDIgMTI3LjIgODIuNzQyMiAxMjcuMjY4IDgyLjg3NEMxMjcuMzQxIDgzLjAwMSAxMjcuNDQxIDgzLjA4NjQgMTI3LjU2OCA4My4xMzA0QzEyNy42OTUgODMuMTc0MyAxMjcuODQ0IDgzLjE5NjMgMTI4LjAxNSA4My4xOTYzQzEyOC4xMzcgODMuMTk2MyAxMjguMjU0IDgzLjE4OSAxMjguMzY3IDgzLjE3NDNDMTI4LjQ3OSA4My4xNTk3IDEyOC41NjkgODMuMTQ1IDEyOC42MzggODMuMTMwNEwxMjguNjQ1IDg0LjQ3OEMxMjguNDk5IDg0LjUyMiAxMjguMzI4IDg0LjU2MSAxMjguMTMyIDg0LjU5NTJDMTI3Ljk0MiA4NC42Mjk0IDEyNy43MjIgODQuNjQ2NSAxMjcuNDczIDg0LjY0NjVDMTI3LjA2OCA4NC42NDY1IDEyNi43MDkgODQuNTc1NyAxMjYuMzk2IDg0LjQzNDFDMTI2LjA4NCA4NC4yODc2IDEyNS44NCA4NC4wNTA4IDEyNS42NjQgODMuNzIzNkMxMjUuNDg4IDgzLjM5NjUgMTI1LjQgODIuOTYxOSAxMjUuNCA4Mi40MTk5Vjc0LjYzNDNaTTEzMy4xNzkgODMuMjQwMkMxMzMuNDY3IDgzLjI0MDIgMTMzLjcyNiA4My4xODQxIDEzMy45NTUgODMuMDcxOEMxMzQuMTg5IDgyLjk1NDYgMTM0LjM3NyA4Mi43OTM1IDEzNC41MTkgODIuNTg4NEMxMzQuNjY2IDgyLjM4MzMgMTM0Ljc0NiA4Mi4xNDY1IDEzNC43NjEgODEuODc3OUgxMzYuNDIzQzEzNi40MTQgODIuMzkwNiAxMzYuMjYyIDgyLjg1NjkgMTM1Ljk2OSA4My4yNzY5QzEzNS42NzYgODMuNjk2OCAxMzUuMjg4IDg0LjAzMTIgMTM0LjgwNSA4NC4yODAzQzEzNC4zMjEgODQuNTI0NCAxMzMuNzg3IDg0LjY0NjUgMTMzLjIwMSA4NC42NDY1QzEzMi41OTUgODQuNjQ2NSAxMzIuMDY4IDg0LjU0MzkgMTMxLjYxOSA4NC4zMzg5QzEzMS4xNjkgODQuMTI4OSAxMzAuNzk2IDgzLjg0MDggMTMwLjQ5OCA4My40NzQ2QzEzMC4yIDgzLjEwODQgMTI5Ljk3NiA4Mi42ODYgMTI5LjgyNCA4Mi4yMDc1QzEyOS42NzggODEuNzI5IDEyOS42MDQgODEuMjE2MyAxMjkuNjA0IDgwLjY2OTRWODAuNDEzMUMxMjkuNjA0IDc5Ljg2NjIgMTI5LjY3OCA3OS4zNTM1IDEyOS44MjQgNzguODc1QzEyOS45NzYgNzguMzkxNiAxMzAuMiA3Ny45NjY4IDEzMC40OTggNzcuNjAwNkMxMzAuNzk2IDc3LjIzNDQgMTMxLjE2OSA3Ni45NDg3IDEzMS42MTkgNzYuNzQzN0MxMzIuMDY4IDc2LjUzMzcgMTMyLjU5MyA3Ni40Mjg3IDEzMy4xOTMgNzYuNDI4N0MxMzMuODI4IDc2LjQyODcgMTM0LjM4NSA3Ni41NTU3IDEzNC44NjMgNzYuODA5NkMxMzUuMzQyIDc3LjA1ODYgMTM1LjcxOCA3Ny40MDc3IDEzNS45OTEgNzcuODU2OUMxMzYuMjcgNzguMzAxMyAxMzYuNDE0IDc4LjgxODggMTM2LjQyMyA3OS40MDk3SDEzNC43NjFDMTM0Ljc0NiA3OS4xMTY3IDEzNC42NzMgNzguODUzIDEzNC41NDEgNzguNjE4N0MxMzQuNDE0IDc4LjM3OTQgMTM0LjIzMyA3OC4xODkgMTMzLjk5OSA3OC4wNDc0QzEzMy43NyA3Ny45MDU4IDEzMy40OTQgNzcuODM1IDEzMy4xNzEgNzcuODM1QzEzMi44MTUgNzcuODM1IDEzMi41MiA3Ny45MDgyIDEzMi4yODUgNzguMDU0N0MxMzIuMDUxIDc4LjE5NjMgMTMxLjg2OCA3OC4zOTE2IDEzMS43MzYgNzguNjQwNkMxMzEuNjA0IDc4Ljg4NDggMTMxLjUwOSA3OS4xNjA2IDEzMS40NSA3OS40NjgzQzEzMS4zOTYgNzkuNzcxIDEzMS4zNyA4MC4wODU5IDEzMS4zNyA4MC40MTMxVjgwLjY2OTRDMTMxLjM3IDgwLjk5NjYgMTMxLjM5NiA4MS4zMTQgMTMxLjQ1IDgxLjYyMTZDMTMxLjUwNCA4MS45MjkyIDEzMS41OTcgODIuMjA1MSAxMzEuNzI5IDgyLjQ0OTJDMTMxLjg2NSA4Mi42ODg1IDEzMi4wNTEgODIuODgxMyAxMzIuMjg1IDgzLjAyNzhDMTMyLjUyIDgzLjE2OTQgMTMyLjgxNyA4My4yNDAyIDEzMy4xNzkgODMuMjQwMlpNMTM5LjUyMSA3My4yNVY4NC41SDEzNy43NjRWNzMuMjVIMTM5LjUyMVpNMTM5LjIxNCA4MC4yNDQ2TDEzOC42NDMgODAuMjM3M0MxMzguNjQ3IDc5LjY5MDQgMTM4LjcyMyA3OS4xODUxIDEzOC44NyA3OC43MjEyQzEzOS4wMjEgNzguMjU3MyAxMzkuMjMxIDc3Ljg1NDUgMTM5LjUgNzcuNTEyN0MxMzkuNzczIDc3LjE2NiAxNDAuMSA3Ni44OTk5IDE0MC40ODEgNzYuNzE0NEMxNDAuODYyIDc2LjUyMzkgMTQxLjI4NCA3Ni40Mjg3IDE0MS43NDggNzYuNDI4N0MxNDIuMTM5IDc2LjQyODcgMTQyLjQ5IDc2LjQ4MjQgMTQyLjgwMyA3Ni41ODk4QzE0My4xMiA3Ni42OTczIDE0My4zOTQgNzYuODcwNiAxNDMuNjIzIDc3LjEwOTlDMTQzLjg1MyA3Ny4zNDQyIDE0NC4wMjYgNzcuNjUxOSAxNDQuMTQzIDc4LjAzMjdDMTQ0LjI2NSA3OC40MDg3IDE0NC4zMjYgNzguODY3NyAxNDQuMzI2IDc5LjQwOTdWODQuNUgxNDIuNTU0Vjc5LjM5NUMxNDIuNTU0IDc5LjAxNDIgMTQyLjQ5OCA3OC43MTE0IDE0Mi4zODUgNzguNDg2OEMxNDIuMjc4IDc4LjI2MjIgMTQyLjExOSA3OC4xMDExIDE0MS45MDkgNzguMDAzNEMxNDEuNjk5IDc3LjkwMDkgMTQxLjQ0MyA3Ny44NDk2IDE0MS4xNCA3Ny44NDk2QzE0MC44MjMgNzcuODQ5NiAxNDAuNTQyIDc3LjkxMzEgMTQwLjI5OCA3OC4wNEMxNDAuMDU5IDc4LjE2NyAxMzkuODU4IDc4LjM0MDMgMTM5LjY5NyA3OC41NjAxQzEzOS41MzYgNzguNzc5OCAxMzkuNDE0IDc5LjAzMzcgMTM5LjMzMSA3OS4zMjE4QzEzOS4yNTMgNzkuNjA5OSAxMzkuMjE0IDc5LjkxNzUgMTM5LjIxNCA4MC4yNDQ2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzM2MzlfMTU1Njc0Ij4KPHJlY3QgeD0iMC41IiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgcng9IjQiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==", + "description": "Sends the RPC call to the device or updates attribute/time-series when the user toggles the slider. Widget settings will enable you to configure behavior how to fetch the initial state and what to trigger when turn on/off states.", + "descriptor": { + "type": "rpc", + "sizeX": 3.5, + "sizeY": 1, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.rpcWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '280px',\n previewHeight: '80px',\n embedTitlePanel: true,\n displayRpcMessageToast: false\n };\n};\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-single-switch-widget-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-single-switch-basic-config", + "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{},\"title\":\"Single Switch\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"actions\":{},\"widgetCss\":\"\",\"noDataDisplayMessage\":\"\",\"titleFont\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":null,\"weight\":null,\"style\":null,\"lineHeight\":\"1.6\"},\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"titleIcon\":\"\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"14px\",\"configMode\":\"basic\"}" + }, + "tags": [ + "command", + "downlink", + "device configuration", + "device control", + "invocation", + "remote method", + "remote function", + "interface", + "subroutine call", + "inter-process communication", + "server request" + ] +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java index 9abeb57e71..4d11a388ff 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java @@ -20,6 +20,7 @@ import io.swagger.annotations.ApiParam; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -43,6 +44,8 @@ import org.thingsboard.server.service.query.EntityQueryService; import org.thingsboard.server.service.security.permission.Operation; import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_SCOPE_ALLOWED_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_SCOPE_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION; @@ -119,14 +122,16 @@ public class EntityQueryController extends BaseController { @ApiParam(value = "Include all unique time-series keys to the result.") @RequestParam("timeseries") boolean isTimeseries, @ApiParam(value = "Include all unique attribute keys to the result.") - @RequestParam("attributes") boolean isAttributes) throws ThingsboardException { + @RequestParam("attributes") boolean isAttributes, + @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES) + @RequestParam(value = "scope", required = false) String scope) throws ThingsboardException { TenantId tenantId = getTenantId(); checkNotNull(query); EntityDataPageLink pageLink = query.getPageLink(); if (pageLink.getPageSize() > MAX_PAGE_SIZE) { pageLink.setPageSize(MAX_PAGE_SIZE); } - return entityQueryService.getKeysByQuery(getCurrentUser(), tenantId, query, isTimeseries, isAttributes); + return entityQueryService.getKeysByQuery(getCurrentUser(), tenantId, query, isTimeseries, isAttributes, scope); } } diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 0453a26ff6..2865e4a01a 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -225,7 +225,7 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public DeferredResult getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, - boolean isTimeseries, boolean isAttributes) { + boolean isTimeseries, boolean isAttributes, String attributesScope) { final DeferredResult response = new DeferredResult<>(); if (!isAttributes && !isTimeseries) { replyWithEmptyResponse(response); @@ -253,7 +253,7 @@ public class DefaultEntityQueryService implements EntityQueryService { if (isAttributes) { Map> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType)); List>> futures = new ArrayList<>(typesMap.size()); - typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, type, entityIds)))); + typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, type, entityIds, attributesScope)))); attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> { if (CollectionUtils.isEmpty(lists)) { return Collections.emptyList(); diff --git a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java index 0ebc1b43f4..fd0f05df2f 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java @@ -38,6 +38,6 @@ public interface EntityQueryService { long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query); DeferredResult getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, - boolean isTimeseries, boolean isAttributes); + boolean isTimeseries, boolean isAttributes, String attributesScope); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java index 4c05438d03..3dd9626739 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java @@ -47,4 +47,6 @@ public interface AttributesService { List findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List entityIds); + List findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List entityIds, String scope); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java index 4a6ab9e3dc..08a9419885 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java @@ -44,4 +44,6 @@ public interface AttributesDao { List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId); List findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List entityIds); + + List findAllKeysByEntityIdsAndAttributeType(TenantId tenantId, EntityType entityType, List entityIds, String attributeType); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index cba126d832..342cb26485 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -83,6 +84,15 @@ public class BaseAttributesService implements AttributesService { return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds); } + @Override + public List findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List entityIds, String scope) { + if (StringUtils.isEmpty(scope)) { + return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds); + } else { + return attributesDao.findAllKeysByEntityIdsAndAttributeType(tenantId, entityType, entityIds, scope); + } + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) { validate(entityId, scope); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java index 6d31f6db1d..1666015a48 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java @@ -220,6 +220,15 @@ public class CachedAttributesService implements AttributesService { return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds); } + @Override + public List findAllKeysByEntityIds(TenantId tenantId, EntityType entityType, List entityIds, String scope) { + if (StringUtils.isEmpty(scope)) { + return attributesDao.findAllKeysByEntityIds(tenantId, entityType, entityIds); + } else { + return attributesDao.findAllKeysByEntityIdsAndAttributeType(tenantId, entityType, entityIds, scope); + } + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) { validate(entityId, scope); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java index 7fafbc177d..622a3298b9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java @@ -59,5 +59,11 @@ public interface AttributeKvRepository extends JpaRepository findAllKeysByEntityIds(@Param("entityType") String entityType, @Param("entityIds") List entityIds); + + @Query(value = "SELECT DISTINCT attribute_key FROM attribute_kv WHERE entity_type = :entityType " + + "AND entity_id in :entityIds AND attribute_type = :attributeType ORDER BY attribute_key", nativeQuery = true) + List findAllKeysByEntityIdsAndAttributeType(@Param("entityType") String entityType, + @Param("entityIds") List entityIds, + @Param("attributeType") String attributeType); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java index fb300f17ab..cb51a7d8ad 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java @@ -153,6 +153,12 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl .findAllKeysByEntityIds(entityType.name(), entityIds.stream().map(EntityId::getId).collect(Collectors.toList())); } + @Override + public List findAllKeysByEntityIdsAndAttributeType(TenantId tenantId, EntityType entityType, List entityIds, String attributeType) { + return attributeKvRepository + .findAllKeysByEntityIdsAndAttributeType(entityType.name(), entityIds.stream().map(EntityId::getId).collect(Collectors.toList()), attributeType); + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, String attributeType, AttributeKvEntry attribute) { AttributeKvEntity entity = new AttributeKvEntity(); diff --git a/ui-ngx/src/app/core/api/alias-controller.ts b/ui-ngx/src/app/core/api/alias-controller.ts index d23359d5e6..bf7399cf6d 100644 --- a/ui-ngx/src/app/core/api/alias-controller.ts +++ b/ui-ngx/src/app/core/api/alias-controller.ts @@ -16,7 +16,14 @@ import { AliasInfo, IAliasController, StateControllerHolder, StateEntityInfo } from '@core/api/widget-api.models'; import { forkJoin, Observable, of, ReplaySubject, Subject } from 'rxjs'; -import { Datasource, DatasourceType, datasourceTypeTranslationMap } from '@app/shared/models/widget.models'; +import { + Datasource, + DatasourceType, + datasourceTypeTranslationMap, + TargetDevice, + TargetDeviceType, + targetDeviceValid +} from '@app/shared/models/widget.models'; import { deepClone, isDefinedAndNotNull, isEqual } from '@core/utils'; import { EntityService } from '@core/http/entity.service'; import { UtilsService } from '@core/services/utils.service'; @@ -24,8 +31,6 @@ import { AliasFilterType, EntityAliases, SingleEntityFilter } from '@shared/mode import { EntityInfo } from '@shared/models/entity.models'; import { map, mergeMap } from 'rxjs/operators'; import { - AlarmFilter, - AlarmFilterConfig, createDefaultEntityDataPageLink, Filter, FilterInfo, @@ -53,10 +58,10 @@ export class AliasController implements IAliasController { filters: Filters; userFilters: Filters; - resolvedAliases: {[aliasId: string]: AliasInfo} = {}; - resolvedAliasesObservable: {[aliasId: string]: Observable} = {}; + resolvedAliases: { [aliasId: string]: AliasInfo } = {}; + resolvedAliasesObservable: { [aliasId: string]: Observable } = {}; - resolvedAliasesToStateEntities: {[aliasId: string]: StateEntityInfo} = {}; + resolvedAliasesToStateEntities: { [aliasId: string]: StateEntityInfo } = {}; constructor(private utils: UtilsService, private entityService: EntityService, @@ -250,6 +255,24 @@ export class AliasController implements IAliasController { ); } + resolveSingleEntityInfoForDeviceId(deviceId: string): Observable { + const entityFilter = singleEntityFilterFromDeviceId(deviceId); + return this.entityService.findSingleEntityInfoByEntityFilter(entityFilter, + {ignoreLoading: true, ignoreErrors: true}); + } + + resolveSingleEntityInfoForTargetDevice(targetDevice: TargetDevice): Observable { + if (targetDeviceValid(targetDevice)) { + if (targetDevice.type === TargetDeviceType.entity) { + return this.resolveSingleEntityInfo(targetDevice.entityAliasId); + } else { + return this.resolveSingleEntityInfoForDeviceId(targetDevice.deviceId); + } + } else { + return of(null); + } + } + private resolveDatasource(datasource: Datasource, forceFilter = false): Observable { const newDatasource = deepClone(datasource); if (newDatasource.type === DatasourceType.entity diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 34e214524c..fa701b315c 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -23,7 +23,7 @@ import { DatasourceType, KeyInfo, LegendConfig, - LegendData, + LegendData, TargetDevice, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; @@ -57,6 +57,7 @@ import { IDashboardController } from '@home/components/dashboard-page/dashboard- import { PopoverPlacement } from '@shared/components/popover.models'; import { PersistentRpc } from '@shared/models/rpc.models'; import { EventEmitter } from '@angular/core'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; export interface TimewindowFunctions { onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void; @@ -121,6 +122,8 @@ export interface IAliasController { getEntityAliasId(aliasName: string): string; getInstantAliasInfo(aliasId: string): AliasInfo; resolveSingleEntityInfo(aliasId: string): Observable; + resolveSingleEntityInfoForDeviceId(deviceId: string): Observable; + resolveSingleEntityInfoForTargetDevice(targetDevice: TargetDevice): Observable; resolveDatasources(datasources: Array, singleEntity?: boolean, pageSize?: number): Observable>; resolveAlarmSource(alarmSource: Datasource): Observable; getEntityAliases(): EntityAliases; @@ -208,6 +211,7 @@ export class WidgetSubscriptionContext { entityDataService: EntityDataService; alarmDataService: AlarmDataService; utils: UtilsService; + dashboardUtils: DashboardUtilsService; raf: RafService; widgetUtils: IWidgetUtils; getServerTimeDiff: () => Observable; @@ -250,6 +254,7 @@ export interface WidgetSubscriptionOptions { ignoreDataUpdateOnIntervalTick?: boolean; targetDeviceAliasIds?: Array; targetDeviceIds?: Array; + targetDevice?: TargetDevice; useDashboardTimewindow?: boolean; displayTimewindow?: boolean; timeWindowConfig?: Timewindow; @@ -305,8 +310,7 @@ export interface IWidgetSubscription { alarms?: PageData; alarmSource?: Datasource; - targetDeviceAliasIds?: Array; - targetDeviceIds?: Array; + targetEntityId?: EntityId; rpcEnabled?: boolean; executingRpcRequest?: boolean; diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index 4f0f399bd0..ca2fdf21eb 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/ui-ngx/src/app/core/api/widget-subscription.ts @@ -34,6 +34,9 @@ import { LegendData, LegendKey, LegendKeyData, + TargetDevice, + TargetDeviceType, + targetDeviceValid, widgetType } from '@app/shared/models/widget.models'; import { HttpErrorResponse } from '@angular/common/http'; @@ -200,9 +203,6 @@ export class WidgetSubscription implements IWidgetSubscription { loadingData: boolean; - targetDeviceAliasIds?: Array; - targetDeviceIds?: Array; - executingRpcRequest: boolean; rpcEnabled: boolean; rpcErrorText: string; @@ -213,9 +213,10 @@ export class WidgetSubscription implements IWidgetSubscription { cafs: {[cafId: string]: CancelAnimationFrame} = {}; hasResolvedData = false; - targetDeviceAliasId: string; + targetEntityId?: EntityId; + targetEntityName?: string; + targetDevice: TargetDevice; targetDeviceId: string; - targetDeviceName: string; executingSubjects: Array>; subscribed = false; @@ -242,11 +243,22 @@ export class WidgetSubscription implements IWidgetSubscription { this.callbacks.onRpcFailed = this.callbacks.onRpcFailed || (() => {}); this.callbacks.onRpcErrorCleared = this.callbacks.onRpcErrorCleared || (() => {}); - this.targetDeviceAliasIds = options.targetDeviceAliasIds; - this.targetDeviceIds = options.targetDeviceIds; - - this.targetDeviceAliasId = null; - this.targetDeviceId = null; + this.targetDevice = options.targetDevice; + if (!targetDeviceValid(this.targetDevice)) { + if (options.targetDeviceAliasIds && options.targetDeviceAliasIds.length) { + this.targetDevice = { + type: TargetDeviceType.entity, + entityAliasId: options.targetDeviceAliasIds[0] + }; + } else if (options.targetDeviceIds && options.targetDeviceIds.length) { + this.targetDevice = { + type: TargetDeviceType.device, + entityAliasId: options.targetDeviceIds[0] + }; + } + } + this.targetEntityId = null; + this.targetEntityName = null; this.rpcRejection = null; this.rpcErrorText = null; @@ -299,7 +311,7 @@ export class WidgetSubscription implements IWidgetSubscription { this.callbacks.legendDataUpdated = this.callbacks.legendDataUpdated || (() => {}); this.callbacks.timeWindowUpdated = this.callbacks.timeWindowUpdated || (() => {}); - this.configuredDatasources = this.ctx.utils.validateDatasources(options.datasources); + this.configuredDatasources = this.ctx.dashboardUtils.validateAndUpdateDatasources(options.datasources); this.datasourcesOptional = options.datasourcesOptional; this.entityDataListeners = []; this.hasDataPageLink = options.hasDataPageLink; @@ -373,50 +385,35 @@ export class WidgetSubscription implements IWidgetSubscription { private initRpc(): Observable { const initRpcSubject = new ReplaySubject(); - if (this.targetDeviceAliasIds && this.targetDeviceAliasIds.length > 0) { - this.targetDeviceAliasId = this.targetDeviceAliasIds[0]; - this.ctx.aliasController.resolveSingleEntityInfo(this.targetDeviceAliasId).subscribe( - (entityInfo) => { - if (entityInfo && entityInfo.entityType === EntityType.DEVICE) { - this.targetDeviceId = entityInfo.id; - this.targetDeviceName = entityInfo.name; - if (this.targetDeviceId) { - this.rpcEnabled = true; - } else { - this.rpcEnabled = this.ctx.utils.widgetEditMode; - } - this.hasResolvedData = this.rpcEnabled; - this.callbacks.rpcStateChanged(this); - initRpcSubject.next(); - initRpcSubject.complete(); - } else { - this.rpcEnabled = false; - this.callbacks.rpcStateChanged(this); - initRpcSubject.next(); - initRpcSubject.complete(); - } - }, - () => { - this.rpcEnabled = false; - this.callbacks.rpcStateChanged(this); - initRpcSubject.next(); - initRpcSubject.complete(); + this.ctx.aliasController.resolveSingleEntityInfoForTargetDevice(this.targetDevice).subscribe({ + next: (entityInfo) => { + if (entityInfo?.id) { + this.targetEntityId = { + id: entityInfo.id, + entityType: entityInfo.entityType + }; + this.targetEntityName = entityInfo.name; } - ); - } else { - if (this.targetDeviceIds && this.targetDeviceIds.length > 0) { - this.targetDeviceId = this.targetDeviceIds[0]; - } - if (this.targetDeviceId) { - this.rpcEnabled = true; - } else { - this.rpcEnabled = this.ctx.utils.widgetEditMode; + if (entityInfo?.entityType === EntityType.DEVICE) { + this.targetDeviceId = entityInfo.id; + } + if (this.targetDeviceId) { + this.rpcEnabled = true; + } else { + this.rpcEnabled = this.ctx.utils.widgetEditMode; + } + this.hasResolvedData = true; + this.callbacks.rpcStateChanged(this); + initRpcSubject.next(); + initRpcSubject.complete(); + }, + error: () => { + this.rpcEnabled = false; + this.callbacks.rpcStateChanged(this); + initRpcSubject.next(); + initRpcSubject.complete(); } - this.hasResolvedData = true; - this.callbacks.rpcStateChanged(this); - initRpcSubject.next(); - initRpcSubject.complete(); - } + }); return initRpcSubject.asObservable(); } @@ -573,12 +570,10 @@ export class WidgetSubscription implements IWidgetSubscription { let entityLabel: string; let entityDescription: string; if (this.type === widgetType.rpc) { - if (this.targetDeviceId) { - entityId = { - entityType: EntityType.DEVICE, - id: this.targetDeviceId - }; - entityName = this.targetDeviceName; + if (this.targetEntityId) { + entityId = this.targetEntityId; + entityName = this.targetEntityName; + entityLabel = this.targetEntityName; } } else if (this.type === widgetType.alarm) { if (this.alarmSource && this.alarmSource.entityType && this.alarmSource.entityId) { @@ -845,12 +840,14 @@ export class WidgetSubscription implements IWidgetSubscription { this.ctx.deviceService.sendTwoWayRpcCommand(this.targetDeviceId, requestBody)).pipe( switchMap((response) => { if (persistent && persistentPollingInterval > 0) { - return timer(persistentPollingInterval / 2, persistentPollingInterval).pipe( + const pollingInterval = Math.max(persistentPollingInterval, 1000); + const initialTimeout = timeout ? Math.min(timeout + 1000, pollingInterval) : pollingInterval; + return timer(initialTimeout, pollingInterval).pipe( switchMap(() => this.ctx.deviceService.getPersistedRpc(response.rpcId, true)), filter(persistentRespons => persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED), switchMap(persistentResponse => { - if (persistentResponse.status === RpcStatus.TIMEOUT) { + if ([RpcStatus.TIMEOUT, RpcStatus.EXPIRED].includes(persistentResponse.status)) { return throwError({status: 504}); } else if (persistentResponse.status === RpcStatus.FAILED) { return throwError({status: 502, statusText: persistentResponse.response.error}); @@ -1091,7 +1088,8 @@ export class WidgetSubscription implements IWidgetSubscription { } private checkRpcTarget(aliasIds: Array): boolean { - return aliasIds.indexOf(this.targetDeviceAliasId) > -1; + return this.targetDevice?.type === TargetDeviceType.entity && + aliasIds.indexOf(this.targetDevice.entityAliasId) > -1; } private checkAlarmSource(aliasIds: Array): boolean { @@ -1184,7 +1182,7 @@ export class WidgetSubscription implements IWidgetSubscription { } private updateDataSubscriptions() { - this.configuredDatasources = this.ctx.utils.validateDatasources(this.options.datasources); + this.configuredDatasources = this.ctx.dashboardUtils.validateAndUpdateDatasources(this.options.datasources); if (!this.ctx.aliasController) { this.configuredDatasources = deepClone(this.configuredDatasources); this.hasResolvedData = true; diff --git a/ui-ngx/src/app/core/http/device.service.ts b/ui-ngx/src/app/core/http/device.service.ts index 8a30cc3cea..516f5993fa 100644 --- a/ui-ngx/src/app/core/http/device.service.ts +++ b/ui-ngx/src/app/core/http/device.service.ts @@ -148,11 +148,11 @@ export class DeviceService { } public sendOneWayRpcCommand(deviceId: string, requestBody: any, config?: RequestConfig): Observable { - return this.http.post(`/api/rpc/oneway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config)); + return this.http.post(`/api/rpc/oneway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config)); } public sendTwoWayRpcCommand(deviceId: string, requestBody: any, config?: RequestConfig): Observable { - return this.http.post(`/api/rpc/twoway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config)); + return this.http.post(`/api/rpc/twoway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config)); } public getPersistedRpc(rpcId: string, fullResponse = false, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index e952fd4505..067ff6c188 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -501,9 +501,13 @@ export class EntityService { } public findEntityKeysByQuery(query: EntityDataQuery, attributes = true, timeseries = true, - config?: RequestConfig): Observable { + scope?: AttributeScope, config?: RequestConfig): Observable { + let url = `/api/entitiesQuery/find/keys?attributes=${attributes}×eries=${timeseries}`; + if (scope) { + url += `&scope=${scope}`; + } return this.http.post( - `/api/entitiesQuery/find/keys?attributes=${attributes}×eries=${timeseries}`, + url, query, defaultHttpOptionsFromConfig(config)); } @@ -831,7 +835,14 @@ export class EntityService { } public getEntityKeysByEntityFilter(filter: EntityFilter, types: DataKeyType[], - entityTypes?: EntityType[], config?: RequestConfig): Observable> { + entityTypes?: EntityType[], + config?: RequestConfig): Observable> { + return this.getEntityKeysByEntityFilterAndScope(filter, types, entityTypes, null, config); + } + + public getEntityKeysByEntityFilterAndScope(filter: EntityFilter, types: DataKeyType[], + entityTypes?: EntityType[], scope?: AttributeScope, + config?: RequestConfig): Observable> { if (!types.length) { return of([]); } @@ -842,7 +853,7 @@ export class EntityService { pageLink: createDefaultEntityDataPageLink(100), }; entitiesKeysByQuery$ = this.findEntityKeysByQuery(dataQuery, types.includes(DataKeyType.attribute), - types.includes(DataKeyType.timeseries), config); + types.includes(DataKeyType.timeseries), scope, config); } else { entitiesKeysByQuery$ = of({ attribute: [], diff --git a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts index 314b5f7d8b..502df634f3 100644 --- a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts +++ b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts @@ -31,7 +31,7 @@ import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; import { parseHttpErrorMessage } from '@core/utils'; -let tmpHeaders = {}; +const tmpHeaders = {}; @Injectable() export class GlobalHttpInterceptor implements HttpInterceptor { @@ -85,7 +85,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor { if (newReq) { return this.handleRequest(newReq, next); } else { - return throwError(new Error('Could not get JWT token from store.')); + return throwError(() => new Error('Could not get JWT token from store.')); } } @@ -136,37 +136,34 @@ export class GlobalHttpInterceptor implements HttpInterceptor { const errorMessageWithTimeout = parseHttpErrorMessage(errorResponse, this.translate, req.responseType); this.showError(errorMessageWithTimeout.message, errorMessageWithTimeout.timeout); } - return throwError(errorResponse); + return throwError(() => errorResponse); } private retryRequest(req: HttpRequest, next: HttpHandler): Observable> { const thisTimeout = 1000 + Math.random() * 3000; return of(null).pipe( delay(thisTimeout), - mergeMap(() => { - return this.jwtIntercept(req, next); - } + mergeMap(() => this.jwtIntercept(req, next) )); } private refreshTokenAndRetry(req: HttpRequest, next: HttpHandler): Observable> { - return this.authService.refreshJwtToken().pipe(switchMap(() => { - return this.jwtIntercept(req, next); - }), - catchError((err: Error) => { - this.authService.logout(true, true); - const message = err ? err.message : 'Unauthorized!'; - return this.handleResponseError(req, next, new HttpErrorResponse({error: {message, timeout: 200}, status: 401})); - })); + return this.authService.refreshJwtToken().pipe( + catchError((err: Error) => { + this.authService.logout(true, true); + const message = err ? err.message : 'Unauthorized!'; + return this.handleResponseError(req, next, new HttpErrorResponse({error: {message, timeout: 200}, status: 401})); + }), + switchMap(() => this.jwtIntercept(req, next)), + ); } private updateAuthorizationHeader(req: HttpRequest): HttpRequest { const jwtToken = AuthService.getJwtToken(); if (jwtToken) { + tmpHeaders[this.AUTH_HEADER_NAME] = `${this.AUTH_SCHEME}${jwtToken}`; req = req.clone({ - setHeaders: (tmpHeaders = {}, - tmpHeaders[this.AUTH_HEADER_NAME] = '' + this.AUTH_SCHEME + jwtToken, - tmpHeaders) + setHeaders: tmpHeaders }); return req; } else { @@ -174,7 +171,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor { } } - private isInternalUrlPrefix(url): boolean { + private isInternalUrlPrefix(url: string): boolean { for (const index in this.internalUrlPrefixes) { if (url.startsWith(this.internalUrlPrefixes[index])) { return true; @@ -183,7 +180,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor { return false; } - private isTokenBasedAuthEntryPoint(url): boolean { + private isTokenBasedAuthEntryPoint(url: string): boolean { return url.startsWith('/api/') && !url.startsWith(Constants.entryPoints.login) && !url.startsWith(Constants.entryPoints.tokenRefresh) && diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index 23fd09701c..ae4d2e5c41 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -29,20 +29,15 @@ import { GridSettings, WidgetLayout } from '@shared/models/dashboard.models'; -import { - deepClone, - isDefined, - isDefinedAndNotNull, - isEmptyStr, - isNotEmptyStr, - isString, - isUndefined -} from '@core/utils'; +import { deepClone, isDefined, isDefinedAndNotNull, isNotEmptyStr, isString, isUndefined } from '@core/utils'; import { Datasource, datasourcesHasOnlyComparisonAggregation, DatasourceType, - defaultLegendConfig, isValidWidgetFullFqn, + defaultLegendConfig, + isValidWidgetFullFqn, + TargetDevice, + TargetDeviceType, Widget, WidgetConfig, WidgetConfigMode, @@ -124,7 +119,7 @@ export class DashboardUtilsService { } } const datasourcesByAliasId: {[aliasId: string]: Array} = {}; - const targetDevicesByAliasId: {[aliasId: string]: Array>} = {}; + const targetDevicesByAliasId: {[aliasId: string]: Array} = {}; for (const widgetId of Object.keys(dashboard.configuration.widgets)) { const widget = dashboard.configuration.widgets[widgetId]; const datasources = widget.type === widgetType.alarm ? [widget.config.alarmSource] : widget.config.datasources; @@ -139,14 +134,14 @@ export class DashboardUtilsService { aliasDatasources.push(datasource); } }); - if (widget.config.targetDeviceAliasIds && widget.config.targetDeviceAliasIds.length) { - const aliasId = widget.config.targetDeviceAliasIds[0]; - let targetDeviceAliasIdsList = targetDevicesByAliasId[aliasId]; - if (!targetDeviceAliasIdsList) { - targetDeviceAliasIdsList = []; - targetDevicesByAliasId[aliasId] = targetDeviceAliasIdsList; + if (widget.config.targetDevice?.type === TargetDeviceType.entity && widget.config.targetDevice.entityAliasId) { + const aliasId = widget.config.targetDevice.entityAliasId; + let targetDevicesList = targetDevicesByAliasId[aliasId]; + if (!targetDevicesList) { + targetDevicesList = []; + targetDevicesByAliasId[aliasId] = targetDevicesList; } - targetDeviceAliasIdsList.push(widget.config.targetDeviceAliasIds); + targetDevicesList.push(widget.config.targetDevice); } } @@ -281,21 +276,23 @@ export class DashboardUtilsService { if (!widgetConfig) { widgetConfig = {}; } - if (!widgetConfig.datasources) { - widgetConfig.datasources = []; - } - widgetConfig.datasources.forEach((datasource) => { - if (datasource.deviceAliasId) { - datasource.type = DatasourceType.entity; - datasource.entityAliasId = datasource.deviceAliasId; - delete datasource.deviceAliasId; - } - }); + widgetConfig.datasources = this.validateAndUpdateDatasources(widgetConfig.datasources); if (type === widgetType.latest) { const onlyHistoryTimewindow = datasourcesHasOnlyComparisonAggregation(widgetConfig.datasources); widgetConfig.timewindow = initModelFromDefaultTimewindow(widgetConfig.timewindow, true, onlyHistoryTimewindow, this.timeService); - } - if (type === widgetType.alarm) { + } else if (type === widgetType.rpc) { + if (widgetConfig.targetDeviceAliasIds && widgetConfig.targetDeviceAliasIds.length) { + widgetConfig.targetDevice = { + type: TargetDeviceType.entity, + entityAliasId: widgetConfig.targetDeviceAliasIds[0] + }; + delete widgetConfig.targetDeviceAliasIds; + } else if (!widgetConfig.targetDevice) { + widgetConfig.targetDevice = { + type: TargetDeviceType.device + }; + } + } else if (type === widgetType.alarm) { if (!widgetConfig.alarmFilterConfig) { widgetConfig.alarmFilterConfig = {}; const alarmFilterConfig = widgetConfig.alarmFilterConfig; @@ -335,6 +332,35 @@ export class DashboardUtilsService { return widgetConfig; } + public validateAndUpdateDatasources(datasources?: Datasource[]): Datasource[] { + if (!datasources) { + datasources = []; + } + datasources.forEach((datasource) => { + if (datasource.deviceAliasId) { + datasource.type = DatasourceType.entity; + datasource.entityAliasId = datasource.deviceAliasId; + delete datasource.deviceAliasId; + } + if (datasource.deviceName) { + datasource.entityName = datasource.deviceName; + delete datasource.deviceName; + } + if (datasource.type === DatasourceType.entity && datasource.entityId) { + datasource.name = datasource.entityName; + } + if (!datasource.dataKeys) { + datasource.dataKeys = []; + } + datasource.dataKeys.forEach(dataKey => { + if (isUndefined(dataKey.label)) { + dataKey.label = dataKey.name; + } + }); + }); + return datasources; + } + public createDefaultLayoutData(): DashboardLayout { return { widgets: {}, @@ -689,7 +715,7 @@ export class DashboardUtilsService { private validateAndUpdateEntityAliases(configuration: DashboardConfiguration, datasourcesByAliasId: {[aliasId: string]: Array}, - targetDevicesByAliasId: {[aliasId: string]: Array>}): DashboardConfiguration { + targetDevicesByAliasId: {[aliasId: string]: Array}): DashboardConfiguration { let entityAlias: EntityAlias; if (isUndefined(configuration.entityAliases)) { configuration.entityAliases = {}; @@ -719,7 +745,7 @@ export class DashboardUtilsService { private validateAndUpdateDeviceAlias(aliasId: string, deviceAlias: any, datasourcesByAliasId: {[aliasId: string]: Array}, - targetDevicesByAliasId: {[aliasId: string]: Array>}): EntityAlias { + targetDevicesByAliasId: {[aliasId: string]: Array}): EntityAlias { aliasId = this.validateAliasId(aliasId, datasourcesByAliasId, targetDevicesByAliasId); const alias = deviceAlias.alias; const entityAlias: EntityAlias = { @@ -748,7 +774,7 @@ export class DashboardUtilsService { private validateAndUpdateEntityAlias(aliasId: string, entityAlias: EntityAlias, datasourcesByAliasId: {[aliasId: string]: Array}, - targetDevicesByAliasId: {[aliasId: string]: Array>}): EntityAlias { + targetDevicesByAliasId: {[aliasId: string]: Array}): EntityAlias { entityAlias.id = this.validateAliasId(aliasId, datasourcesByAliasId, targetDevicesByAliasId); if (!entityAlias.filter) { entityAlias.filter = { @@ -810,7 +836,7 @@ export class DashboardUtilsService { private validateAliasId(aliasId: string, datasourcesByAliasId: {[aliasId: string]: Array}, - targetDevicesByAliasId: {[aliasId: string]: Array>}): string { + targetDevicesByAliasId: {[aliasId: string]: Array}): string { if (!aliasId || !isString(aliasId) || aliasId.length !== 36) { const newAliasId = this.utils.guid(); const aliasDatasources = datasourcesByAliasId[aliasId]; @@ -821,11 +847,11 @@ export class DashboardUtilsService { } ); } - const targetDeviceAliasIdsList = targetDevicesByAliasId[aliasId]; - if (targetDeviceAliasIdsList) { - targetDeviceAliasIdsList.forEach( - (targetDeviceAliasIds) => { - targetDeviceAliasIds[0] = newAliasId; + const targetDevicesList = targetDevicesByAliasId[aliasId]; + if (targetDevicesList) { + targetDevicesList.forEach( + (targetDevice) => { + targetDevice.entityAliasId = newAliasId; } ); } diff --git a/ui-ngx/src/app/core/services/item-buffer.service.ts b/ui-ngx/src/app/core/services/item-buffer.service.ts index 0a21763ce6..eeade82898 100644 --- a/ui-ngx/src/app/core/services/item-buffer.service.ts +++ b/ui-ngx/src/app/core/services/item-buffer.service.ts @@ -20,6 +20,7 @@ import { AliasesInfo, EntityAlias, EntityAliases, EntityAliasInfo } from '@share import { Datasource, DatasourceType, + TargetDeviceType, Widget, WidgetPosition, WidgetSize, @@ -87,7 +88,7 @@ export class ItemBufferService { public prepareWidgetItem(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget): WidgetItem { const aliasesInfo: AliasesInfo = { datasourceAliases: {}, - targetDeviceAliases: {} + targetDeviceAlias: null }; const filtersInfo: FiltersInfo = { datasourceFilters: {} @@ -111,15 +112,11 @@ export class ItemBufferService { } } } - if (widget.config.targetDeviceAliasIds) { - for (let i = 0; i < widget.config.targetDeviceAliasIds.length; i++) { - const targetDeviceAliasId = widget.config.targetDeviceAliasIds[i]; - if (targetDeviceAliasId) { - entityAlias = dashboard.configuration.entityAliases[targetDeviceAliasId]; - if (entityAlias) { - aliasesInfo.targetDeviceAliases[i] = this.prepareAliasInfo(entityAlias); - } - } + if (widget.config.targetDevice?.type === TargetDeviceType.entity && widget.config.targetDevice.entityAliasId) { + const targetDeviceAliasId = widget.config.targetDevice.entityAliasId; + entityAlias = dashboard.configuration.entityAliases[targetDeviceAliasId]; + if (entityAlias) { + aliasesInfo.targetDeviceAlias = this.prepareAliasInfo(entityAlias); } } } @@ -456,11 +453,15 @@ export class ItemBufferService { widget.config.datasources[datasourceIndex].entityAliasId = newAliasId; } } - for (const targetDeviceAliasIndexStr of Object.keys(aliasesInfo.targetDeviceAliases)) { - const targetDeviceAliasIndex = Number(targetDeviceAliasIndexStr); - aliasInfo = aliasesInfo.targetDeviceAliases[targetDeviceAliasIndex]; + if (aliasesInfo.targetDeviceAlias) { + aliasInfo = aliasesInfo.targetDeviceAlias; newAliasId = this.getEntityAliasId(entityAliases, aliasInfo); - widget.config.targetDeviceAliasIds[targetDeviceAliasIndex] = newAliasId; + if (widget.config.targetDevice?.type !== TargetDeviceType.entity) { + widget.config.targetDevice = { + type: TargetDeviceType.entity + }; + } + widget.config.targetDevice.entityAliasId = newAliasId; } return entityAliases; } diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index cf08482bad..440f9c1d3d 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -294,32 +294,6 @@ export class UtilsService { return guid(); } - public validateDatasources(datasources: Array): Array { - datasources.forEach((datasource) => { - if (datasource.type === DatasourceType.device) { - if (datasource.deviceAliasId) { - datasource.type = DatasourceType.entity; - datasource.entityAliasId = datasource.deviceAliasId; - } - if (datasource.deviceName) { - datasource.entityName = datasource.deviceName; - } - } - if (datasource.type === DatasourceType.entity && datasource.entityId) { - datasource.name = datasource.entityName; - } - if (!datasource.dataKeys) { - datasource.dataKeys = []; - } - datasource.dataKeys.forEach(dataKey => { - if (isUndefined(dataKey.label)) { - dataKey.label = dataKey.name; - } - }); - }); - return datasources; - } - public getMaterialColor(index: number) { const colorIndex = index % materialColors.length; return materialColors[colorIndex].value; diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 888a9b9660..0ff2a9087d 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -24,6 +24,7 @@ import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-t import { HttpErrorResponse } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; import { serverErrorCodesTranslations } from '@shared/models/constants'; +import { SubscriptionEntityInfo } from '@core/api/widget-api.models'; const varsRegex = /\${([^}]*)}/g; @@ -426,6 +427,33 @@ export const createLabelFromDatasource = (datasource: Datasource, pattern: strin return label; }; +export const createLabelFromSubscriptionEntityInfo = (entityInfo: SubscriptionEntityInfo, pattern: string): string => { + let label = pattern; + if (!entityInfo) { + return label; + } + let match = varsRegex.exec(pattern); + while (match !== null) { + const variable = match[0]; + const variableName = match[1]; + if (variableName === 'dsName') { + label = label.replace(variable, entityInfo.entityName); + } else if (variableName === 'entityName') { + label = label.replace(variable, entityInfo.entityName); + } else if (variableName === 'deviceName') { + label = label.replace(variable, entityInfo.entityName); + } else if (variableName === 'entityLabel') { + label = label.replace(variable, entityInfo.entityLabel || entityInfo.entityName); + } else if (variableName === 'aliasName') { + label = label.replace(variable, entityInfo.entityName); + } else if (variableName === 'entityDescription') { + label = label.replace(variable, entityInfo.entityDescription); + } + match = varsRegex.exec(pattern); + } + return label; +}; + export const hasDatasourceLabelsVariables = (pattern: string): boolean => varsRegex.test(pattern) !== null; export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number): FormattedData[] { diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts index 9643851863..15791f9588 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts @@ -32,14 +32,14 @@ import { import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { EntityAlias, EntityAliases, EntityAliasFilter } from '@shared/models/alias.models'; -import { DatasourceType, Widget, widgetType } from '@shared/models/widget.models'; +import { DatasourceType, TargetDeviceType, Widget, widgetType } from '@shared/models/widget.models'; import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; -import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { DialogService } from '@core/services/dialog.service'; import { deepClone, isUndefined } from '@core/utils'; import { EntityAliasDialogComponent, EntityAliasDialogData } from './entity-alias-dialog.component'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; export interface EntityAliasesDialogData { entityAliases: EntityAliases; @@ -77,7 +77,7 @@ export class EntityAliasesDialogComponent extends DialogComponent, private fb: UntypedFormBuilder, - private utils: UtilsService, + private dashboardUtils: DashboardUtilsService, private translate: TranslateService, private dialogs: DialogService, private dialog: MatDialog) { @@ -97,15 +97,15 @@ export class EntityAliasesDialogComponent extends DialogComponent { if (widget.type === widgetType.rpc) { - if (widget.config.targetDeviceAliasIds && widget.config.targetDeviceAliasIds.length > 0) { - this.addWidgetTitleToWidgetsMap(widget.config.targetDeviceAliasIds[0], widget.config.title); + if (widget.config.targetDevice?.type === TargetDeviceType.entity && widget.config.targetDevice.entityAliasId) { + this.addWidgetTitleToWidgetsMap(widget.config.targetDevice.entityAliasId, widget.config.title); } } else if (widget.type === widgetType.alarm) { if (widget.config.alarmSource) { this.addWidgetTitleToWidgetsMap(widget.config.alarmSource.entityAliasId, widget.config.title); } } else { - const datasources = this.utils.validateDatasources(widget.config.datasources); + const datasources = this.dashboardUtils.validateAndUpdateDatasources(widget.config.datasources); datasources.forEach((datasource) => { if ([DatasourceType.entity, DatasourceType.entityCount, DatasourceType.alarmCount].includes(datasource.type) && datasource.entityAliasId) { diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html index ff87c2793a..b70159d9b3 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+

{{ (isTelemetry ? 'attribute.add-telemetry' : 'attribute.add') | translate }}

diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts b/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts index 0ac4f79c55..9935ea5bbf 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts @@ -126,9 +126,8 @@ export class AddWidgetToDashboardDialogComponent extends mergeMap((dashboard) => { dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard); return this.selectTargetState(dashboard).pipe( - mergeMap((targetState) => { - return forkJoin([of(dashboard), of(targetState), this.selectTargetLayout(dashboard, targetState)]); - }) + mergeMap((targetState) => + forkJoin([of(dashboard), of(targetState), this.selectTargetLayout(dashboard, targetState)])) ); }) ).subscribe((res) => { @@ -177,7 +176,7 @@ export class AddWidgetToDashboardDialogComponent extends private addWidgetToDashboard(dashboard: Dashboard, targetState: string, targetLayout: DashboardLayoutId) { const aliasesInfo: AliasesInfo = { datasourceAliases: {}, - targetDeviceAliases: {} + targetDeviceAlias: null }; aliasesInfo.datasourceAliases[0] = { alias: this.data.entityName, diff --git a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html index 6725b36960..7c682e6c0c 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html @@ -16,7 +16,7 @@ --> + [formGroup]="attributeFormGroup" (ngSubmit)="update()" style="padding: 5px; width: 375px;">
diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts index 762e56f5d6..c4bbdca1db 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts @@ -39,6 +39,7 @@ import { DialogService } from '@core/services/dialog.service'; import { deepClone, isUndefined } from '@core/utils'; import { Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models'; import { FilterDialogComponent, FilterDialogData } from '@home/components/filter/filter-dialog.component'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; export interface FiltersDialogData { filters: Filters; @@ -77,6 +78,7 @@ export class FiltersDialogComponent extends DialogComponent, private fb: UntypedFormBuilder, private utils: UtilsService, + private dashboardUtils: DashboardUtilsService, private translate: TranslateService, private dialogs: DialogService, private dialog: MatDialog) { @@ -94,7 +96,7 @@ export class FiltersDialogComponent extends DialogComponent { - const datasources = this.utils.validateDatasources(widget.config.datasources); + const datasources = this.dashboardUtils.validateAndUpdateDatasources(widget.config.datasources); datasources.forEach((datasource) => { if (datasource.type === DatasourceType.entity && datasource.filterId) { widgetsTitleList = this.filterToWidgetsMap[datasource.filterId]; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index 5d7439feae..9f8586b12e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -91,6 +91,9 @@ import { import { BarChartWithLabelsBasicConfigComponent } from '@home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component'; +import { + SingSwitchBasicConfigComponent +} from '@home/components/widget/config/basic/rpc/single-switch-basic-config.component'; @NgModule({ declarations: [ @@ -119,7 +122,8 @@ import { LiquidLevelCardBasicConfigComponent, DoughnutBasicConfigComponent, RangeChartBasicConfigComponent, - BarChartWithLabelsBasicConfigComponent + BarChartWithLabelsBasicConfigComponent, + SingSwitchBasicConfigComponent ], imports: [ CommonModule, @@ -152,7 +156,8 @@ import { LiquidLevelCardBasicConfigComponent, DoughnutBasicConfigComponent, RangeChartBasicConfigComponent, - BarChartWithLabelsBasicConfigComponent + BarChartWithLabelsBasicConfigComponent, + SingSwitchBasicConfigComponent ] }) export class BasicWidgetConfigModule { @@ -179,5 +184,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type + + +
+
widgets.single-switch.behavior
+
+
widgets.rpc-state.initial-state
+ +
+
+
widgets.rpc-state.turn-on
+ +
+
+
widgets.rpc-state.turn-off
+ +
+
+
+
widget-config.appearance
+ + + {{ singleSwitchLayoutTranslationMap.get(layout) | translate }} + + +
+ + {{ 'widgets.single-switch.auto-scale' | translate }} + +
+
+ + {{ 'widgets.single-switch.label' | translate }} + +
+ + + + + + + +
+
+
+ + {{ 'widgets.single-switch.icon' | translate }} + +
+ + + + + + + + +
+
+
+
{{ '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 }} + +
+ + + + + + + +
+
+
+
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widget-config.show-card-buttons
+ + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts new file mode 100644 index 0000000000..ed95847654 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts @@ -0,0 +1,231 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { TargetDevice, WidgetConfig, } from '@shared/models/widget.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { isUndefined } from '@core/utils'; +import { + singleSwitchDefaultSettings, + singleSwitchLayoutImages, + singleSwitchLayouts, + singleSwitchLayoutTranslations, + SingleSwitchWidgetSettings +} from '@home/components/widget/lib/rpc/single-switch-widget.models'; +import { ValueType } from '@shared/models/constants'; + +@Component({ + selector: 'tb-single-switch-basic-config', + templateUrl: './single-switch-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class SingSwitchBasicConfigComponent extends BasicWidgetConfigComponent { + + get targetDevice(): TargetDevice { + return this.singleSwitchWidgetConfigForm.get('targetDevice').value; + } + + singleSwitchLayouts = singleSwitchLayouts; + + singleSwitchLayoutTranslationMap = singleSwitchLayoutTranslations; + singleSwitchLayoutImageMap = singleSwitchLayoutImages; + + valueType = ValueType; + + singleSwitchWidgetConfigForm: UntypedFormGroup; + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.singleSwitchWidgetConfigForm; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: SingleSwitchWidgetSettings = {...singleSwitchDefaultSettings, ...(configData.config.settings || {})}; + this.singleSwitchWidgetConfigForm = this.fb.group({ + targetDevice: [configData.config.targetDevice, []], + + initialState: [settings.initialState, []], + onUpdateState: [settings.onUpdateState, []], + offUpdateState: [settings.offUpdateState, []], + + layout: [settings.layout, []], + autoScale: [settings.autoScale, []], + + showLabel: [settings.showLabel, []], + label: [settings.label, []], + labelFont: [settings.labelFont, []], + labelColor: [settings.labelColor, []], + + showIcon: [settings.showIcon, []], + iconSize: [settings.iconSize, [Validators.min(0)]], + iconSizeUnit: [settings.iconSizeUnit, []], + icon: [settings.icon, []], + iconColor: [settings.iconColor, []], + + switchColorOn: [settings.switchColorOn, []], + switchColorOff: [settings.switchColorOff, []], + switchColorDisabled: [settings.switchColorDisabled, []], + + tumblerColorOn: [settings.tumblerColorOn, []], + tumblerColorOff: [settings.tumblerColorOff, []], + tumblerColorDisabled: [settings.tumblerColorDisabled, []], + + showOnLabel: [settings.showOnLabel, []], + onLabel: [settings.onLabel, []], + onLabelFont: [settings.onLabelFont, []], + onLabelColor: [settings.onLabelColor, []], + + showOffLabel: [settings.showOffLabel, []], + offLabel: [settings.offLabel, []], + offLabelFont: [settings.offLabelFont, []], + offLabelColor: [settings.offLabelColor, []], + + background: [settings.background, []], + + cardButtons: [this.getCardButtons(configData.config), []], + borderRadius: [configData.config.borderRadius, []], + + actions: [configData.config.actions || {}, []] + }); + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + this.widgetConfig.config.targetDevice = config.targetDevice; + + this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; + + this.widgetConfig.config.settings.initialState = config.initialState; + this.widgetConfig.config.settings.onUpdateState = config.onUpdateState; + this.widgetConfig.config.settings.offUpdateState = config.offUpdateState; + + this.widgetConfig.config.settings.layout = config.layout; + this.widgetConfig.config.settings.autoScale = config.autoScale; + + this.widgetConfig.config.settings.showLabel = config.showLabel; + this.widgetConfig.config.settings.label = config.label; + this.widgetConfig.config.settings.labelFont = config.labelFont; + this.widgetConfig.config.settings.labelColor = config.labelColor; + + this.widgetConfig.config.settings.showIcon = config.showIcon; + this.widgetConfig.config.settings.iconSize = config.iconSize; + this.widgetConfig.config.settings.iconSizeUnit = config.iconSizeUnit; + this.widgetConfig.config.settings.icon = config.icon; + this.widgetConfig.config.settings.iconColor = config.iconColor; + + this.widgetConfig.config.settings.switchColorOn = config.switchColorOn; + this.widgetConfig.config.settings.switchColorOff = config.switchColorOff; + this.widgetConfig.config.settings.switchColorDisabled = config.switchColorDisabled; + + this.widgetConfig.config.settings.tumblerColorOn = config.tumblerColorOn; + this.widgetConfig.config.settings.tumblerColorOff = config.tumblerColorOff; + this.widgetConfig.config.settings.tumblerColorDisabled = config.tumblerColorDisabled; + + this.widgetConfig.config.settings.showOnLabel = config.showOnLabel; + this.widgetConfig.config.settings.onLabel = config.onLabel; + this.widgetConfig.config.settings.onLabelFont = config.onLabelFont; + this.widgetConfig.config.settings.onLabelColor = config.onLabelColor; + + this.widgetConfig.config.settings.showOffLabel = config.showOffLabel; + this.widgetConfig.config.settings.offLabel = config.offLabel; + this.widgetConfig.config.settings.offLabelFont = config.offLabelFont; + this.widgetConfig.config.settings.offLabelColor = config.offLabelColor; + + this.widgetConfig.config.settings.background = config.background; + + this.setCardButtons(config.cardButtons, this.widgetConfig.config); + this.widgetConfig.config.borderRadius = config.borderRadius; + + this.widgetConfig.config.actions = config.actions; + return this.widgetConfig; + } + + protected validatorTriggers(): string[] { + return ['showLabel', 'showIcon', 'showOnLabel', 'showOffLabel']; + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + const showLabel: boolean = this.singleSwitchWidgetConfigForm.get('showLabel').value; + const showIcon: boolean = this.singleSwitchWidgetConfigForm.get('showIcon').value; + const showOnLabel: boolean = this.singleSwitchWidgetConfigForm.get('showOnLabel').value; + const showOffLabel: boolean = this.singleSwitchWidgetConfigForm.get('showOffLabel').value; + + if (showLabel) { + this.singleSwitchWidgetConfigForm.get('label').enable(); + this.singleSwitchWidgetConfigForm.get('labelFont').enable(); + this.singleSwitchWidgetConfigForm.get('labelColor').enable(); + } else { + this.singleSwitchWidgetConfigForm.get('label').disable(); + this.singleSwitchWidgetConfigForm.get('labelFont').disable(); + this.singleSwitchWidgetConfigForm.get('labelColor').disable(); + } + + if (showIcon) { + this.singleSwitchWidgetConfigForm.get('iconSize').enable(); + this.singleSwitchWidgetConfigForm.get('iconSizeUnit').enable(); + this.singleSwitchWidgetConfigForm.get('icon').enable(); + this.singleSwitchWidgetConfigForm.get('iconColor').enable(); + } else { + this.singleSwitchWidgetConfigForm.get('iconSize').disable(); + this.singleSwitchWidgetConfigForm.get('iconSizeUnit').disable(); + this.singleSwitchWidgetConfigForm.get('icon').disable(); + this.singleSwitchWidgetConfigForm.get('iconColor').disable(); + } + + if (showOnLabel) { + this.singleSwitchWidgetConfigForm.get('onLabel').enable(); + this.singleSwitchWidgetConfigForm.get('onLabelFont').enable(); + this.singleSwitchWidgetConfigForm.get('onLabelColor').enable(); + } else { + this.singleSwitchWidgetConfigForm.get('onLabel').disable(); + this.singleSwitchWidgetConfigForm.get('onLabelFont').disable(); + this.singleSwitchWidgetConfigForm.get('onLabelColor').disable(); + } + + if (showOffLabel) { + this.singleSwitchWidgetConfigForm.get('offLabel').enable(); + this.singleSwitchWidgetConfigForm.get('offLabelFont').enable(); + this.singleSwitchWidgetConfigForm.get('offLabelColor').enable(); + } else { + this.singleSwitchWidgetConfigForm.get('offLabel').disable(); + this.singleSwitchWidgetConfigForm.get('offLabelFont').disable(); + this.singleSwitchWidgetConfigForm.get('offLabelColor').disable(); + } + + } + + private getCardButtons(config: WidgetConfig): string[] { + const buttons: string[] = []; + if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { + buttons.push('fullscreen'); + } + return buttons; + } + + private setCardButtons(buttons: string[], config: WidgetConfig) { + config.enableFullscreen = buttons.includes('fullscreen'); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html new file mode 100644 index 0000000000..b46e5ccc90 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html @@ -0,0 +1,39 @@ + +
+
+
widget-config.target-device
+ + {{ 'device.device' | translate }} + {{ 'entity.entity-alias' | translate }} + +
+ + + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts new file mode 100644 index 0000000000..c1d09f50b6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts @@ -0,0 +1,166 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { TargetDevice, TargetDeviceType } from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models'; + +@Component({ + selector: 'tb-target-device', + templateUrl: './target-device.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TargetDeviceComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TargetDeviceComponent), + multi: true, + } + ] +}) +export class TargetDeviceComponent implements ControlValueAccessor, OnInit, Validator { + + public get aliasController(): IAliasController { + return this.widgetConfigComponent.aliasController; + } + + public get entityAliasSelectCallbacks(): EntityAliasSelectCallbacks { + return this.widgetConfigComponent.widgetConfigCallbacks; + } + + targetDeviceType = TargetDeviceType; + + entityType = EntityType; + + @Input() + disabled: boolean; + + widgetEditMode = this.utils.widgetEditMode; + + targetDeviceFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private utils: UtilsService, + public translate: TranslateService, + private widgetConfigComponent: WidgetConfigComponent) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + if (!this.targetDeviceFormGroup.valid) { + setTimeout(() => { + this.targetDeviceUpdated(this.targetDeviceFormGroup.value); + }, 0); + } + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.targetDeviceFormGroup.disable({emitEvent: false}); + } else { + this.targetDeviceFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + ngOnInit() { + this.targetDeviceFormGroup = this.fb.group({ + type: [null, !this.widgetEditMode ? [Validators.required] : []], + deviceId: [null, !this.widgetEditMode ? [Validators.required] : []], + entityAliasId: [null, !this.widgetEditMode ? [Validators.required] : []] + }); + this.targetDeviceFormGroup.get('type').valueChanges.subscribe(() => { + this.updateValidators(); + }); + this.targetDeviceFormGroup.valueChanges.subscribe( + () => { + this.targetDeviceUpdated(this.targetDeviceFormGroup.value); + } + ); + } + + writeValue(targetDevice?: TargetDevice): void { + this.targetDeviceFormGroup.patchValue({ + type: targetDevice?.type || TargetDeviceType.device, + deviceId: targetDevice?.deviceId, + entityAliasId: targetDevice?.entityAliasId + }, {emitEvent: false}); + this.updateValidators(); + if (targetDevice?.type === TargetDeviceType.entity && targetDevice?.entityAliasId) { + const entityAliases = this.aliasController.getEntityAliases(); + if (!entityAliases[targetDevice.entityAliasId]) { + this.targetDeviceFormGroup.get('entityAliasId').patchValue(null, {emitEvent: false}); + setTimeout(() => { + this.targetDeviceUpdated(this.targetDeviceFormGroup.value); + }, 0); + } + } + } + + validate(c: UntypedFormControl) { + return (this.targetDeviceFormGroup.valid) ? null : { + targetDevice: { + valid: false, + }, + }; + } + + private targetDeviceUpdated(targetDevice: TargetDevice) { + this.propagateChange(targetDevice); + } + + private updateValidators() { + const type: TargetDeviceType = this.targetDeviceFormGroup.get('type').value; + if (!this.widgetEditMode && type) { + if (type === TargetDeviceType.device) { + this.targetDeviceFormGroup.get('deviceId').enable({emitEvent: false}); + this.targetDeviceFormGroup.get('entityAliasId').disable({emitEvent: false}); + } else { + this.targetDeviceFormGroup.get('deviceId').disable({emitEvent: false}); + this.targetDeviceFormGroup.get('entityAliasId').enable({emitEvent: false}); + } + } else { + this.targetDeviceFormGroup.get('deviceId').disable({emitEvent: false}); + this.targetDeviceFormGroup.get('entityAliasId').disable({emitEvent: false}); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts index fa19f2b2b5..4470fc1327 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts @@ -32,6 +32,7 @@ import { TimewindowConfigPanelComponent } from '@home/components/widget/config/t import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module'; import { TimewindowStyleComponent } from '@home/components/widget/config/timewindow-style.component'; import { TimewindowStylePanelComponent } from '@home/components/widget/config/timewindow-style-panel.component'; +import { TargetDeviceComponent } from '@home/components/widget/config/target-device.component'; @NgModule({ declarations: @@ -43,6 +44,7 @@ import { TimewindowStylePanelComponent } from '@home/components/widget/config/ti DataKeyConfigComponent, DatasourceComponent, DatasourcesComponent, + TargetDeviceComponent, EntityAliasSelectComponent, FilterSelectComponent, TimewindowStyleComponent, @@ -64,6 +66,7 @@ import { TimewindowStylePanelComponent } from '@home/components/widget/config/ti DataKeyConfigComponent, DatasourceComponent, DatasourcesComponent, + TargetDeviceComponent, EntityAliasSelectComponent, FilterSelectComponent, TimewindowStyleComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts index 6373770e38..1132e8bf91 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts @@ -27,6 +27,7 @@ import { DataKey, DatasourceType, KeyInfo, WidgetConfigMode } from '@shared/mode import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { isDefinedAndNotNull } from '@core/utils'; +import { IAliasController } from '@core/api/widget-api.models'; export type WidgetConfigCallbacks = DatasourceCallbacks & WidgetActionCallbacks; @@ -58,6 +59,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement return this.widgetConfigValue; } + get aliasController(): IAliasController { + return this.widgetConfigComponent.aliasController; + } + widgetConfigChangedEmitter = new EventEmitter(); widgetConfigChanged = this.widgetConfigChangedEmitter.asObservable(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widget.models.ts new file mode 100644 index 0000000000..77dbd31ddb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widget.models.ts @@ -0,0 +1,561 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { AttributeData, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { catchError, delay, map, share } from 'rxjs/operators'; +import { UtilsService } from '@core/services/utils.service'; +import { AfterViewInit, ChangeDetectorRef, Directive, Input, OnInit, TemplateRef } from '@angular/core'; +import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { + RpcGetAttributeSettings, + RpcSetAttributeSettings, + RpcSettings, + RpcStateToParamsSettings, + RpcStateToParamsType, + RpcStateWidgetSettings, + RpcTelemetrySettings, + RpcUpdateStateAction +} from '@shared/models/rpc-widget-settings.models'; +import { + RpcDataToStateSettings, + RpcDataToStateType, + RpcInitialStateAction, + RpcInitialStateSettings, + RpcStateBehaviourSettings, + RpcUpdateStateSettings +} from '@app/shared/models/rpc-widget-settings.models'; +import { ValueType } from '@shared/models/constants'; + +@Directive() +// eslint-disable-next-line @angular-eslint/directive-class-suffix +export abstract class BasicRpcStateWidgetComponent> implements OnInit, AfterViewInit { + + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + + settings: S; + + behaviorApi: RpcStateBehaviorApi; + loading$: Observable; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + + value: V; + + error = ''; + + protected constructor(protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + protected cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + this.ctx.$scope.rpcWidget = this; + this.settings = {...this.defaultSettings(), ...this.ctx.settings}; + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + + const behaviourSettings: RpcStateBehaviourSettings = { + initialState: this.settings.initialState, + updateStateByValue: val => this.getUpdateStateSettingsForValue(val) + }; + + const callbacks: RpcStateCallbacks = { + onStateValue: val => this.setValue(val), + onError: err => this.onError(err), + validateStateValue: val => this.validateValue(val) + }; + + this.behaviorApi = new RpcStateBehaviorApi(this.defaultValue(), this.ctx, + behaviourSettings, callbacks, this.stateValueType()); + this.loading$ = this.behaviorApi.loading$.pipe(share()); + } + + ngAfterViewInit(): void { + this.behaviorApi.initState(); + } + + public onInit() { + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + + public clearError() { + this.error = ''; + } + + public updateValue() { + this.behaviorApi.updateState(this.value); + } + + private onError(error: string) { + this.error = error; + this.ctx.detectChanges(); + } + + protected abstract defaultSettings(): S; + + protected abstract getUpdateStateSettingsForValue(value: V): RpcUpdateStateSettings; + + protected stateValueType(): ValueType { + return ValueType.BOOLEAN; + } + + protected defaultValue(): V { + return null; + } + + protected setValue(value: V) { + this.value = value; + } + + protected validateValue(value: any): V { + return value; + } + +} + +export abstract class RpcHasLoading { + + public get loading$() { + return this.loadingSubject.asObservable(); + } + + protected loadingSubject = new BehaviorSubject(false); +} + +export interface RpcStateCallbacks { + onStateValue: (value: V) => void; + validateStateValue: (value: any) => V; + onError: (error: string) => void; +} + +export class RpcStateBehaviorApi extends RpcHasLoading { + + private readonly initialStateGetter: RpcInitialStateGetter; + private readonly stateUpdatersMap: Map>; + + constructor(private state: V, + private ctx: WidgetContext, + private settings: RpcStateBehaviourSettings, + private callbacks: RpcStateCallbacks, + private stateValueType: ValueType) { + super(); + this.initialStateGetter = RpcInitialStateGetter.fromSettings(ctx, settings.initialState, stateValueType, callbacks); + this.stateUpdatersMap = new Map>(); + } + + initState() { + if (this.ctx.defaultSubscription.rpcEnabled) { + this.loadingSubject.next(true); + this.initialStateGetter.initState().subscribe( + { + next: (value) => { + this.state = value; + this.loadingSubject.next(false); + this.callbacks.onStateValue(value); + this.ctx.detectChanges(); + }, + error: (err: any) => { + this.loadingSubject.next(false); + const message = parseError(this.ctx, err); + this.callbacks.onError(message); + } + } + ); + } else { + this.callbacks.onError('Target device is not set!'); + } + } + + updateState(value: V) { + this.callbacks.onError(null); + let updater: RpcStateUpdater; + const updateStateSettings = this.settings.updateStateByValue(value); + if (updateStateSettings) { + updater = this.stateUpdatersMap.get(updateStateSettings); + if (!updater) { + updater = RpcStateUpdater.fromSettings(this.ctx, updateStateSettings); + this.stateUpdatersMap.set(updateStateSettings, updater); + } + } + if (updater) { + this.loadingSubject.next(true); + updater.updateState(value).subscribe( + { + next: () => { + this.state = value; + this.loadingSubject.next(false); + this.ctx.detectChanges(); + }, + error: (err: any) => { + this.loadingSubject.next(false); + const message = parseError(this.ctx, err); + this.callbacks.onStateValue(this.state); + this.callbacks.onError(message); + this.ctx.detectChanges(); + } + }); + } + } + +} + +type RpcDataToStateFunction = (data: any) => V; + +export class RpcDataToStateConverter { + + private readonly dataToStateFunction: RpcDataToStateFunction; + private readonly compareToValue: any; + + constructor(private settings: RpcDataToStateSettings, + private stateValueType: ValueType, + private callbacks: RpcStateCallbacks) { + this.compareToValue = settings.compareToValue; + switch (settings.type) { + case RpcDataToStateType.FUNCTION: + try { + this.dataToStateFunction = new Function('data', settings.dataToStateFunction) as RpcDataToStateFunction; + } catch (e) { + this.dataToStateFunction = (data) => data; + } + break; + case RpcDataToStateType.NONE: + break; + } + } + + dataToStateValue(data: any): V { + let result: V; + switch (this.settings.type) { + case RpcDataToStateType.FUNCTION: + result = data; + try { + result = this.dataToStateFunction(!!data ? JSON.parse(data) : data); + } catch (e) {} + break; + case RpcDataToStateType.NONE: + result = data; + break; + } + if (this.stateValueType === ValueType.BOOLEAN) { + result = (result === this.compareToValue) as any; + } + result = this.callbacks.validateStateValue(result); + return result; + } +} + +export abstract class RpcInitialStateGetter { + + static fromSettings(ctx: WidgetContext, + settings: RpcInitialStateSettings, + stateValueType: ValueType, + callbacks: RpcStateCallbacks): RpcInitialStateGetter { + switch (settings.action) { + case RpcInitialStateAction.DO_NOTHING: + return new RpcDefaultStateGetter(ctx, settings, stateValueType, callbacks); + case RpcInitialStateAction.EXECUTE_RPC: + return new ExecuteRpcStateGetter(ctx, settings, stateValueType, callbacks); + case RpcInitialStateAction.GET_ATTRIBUTE: + return new RpcAttributeStateGetter(ctx, settings, stateValueType, callbacks); + case RpcInitialStateAction.GET_TIME_SERIES: + return new RpcTimeSeriesStateGetter(ctx, settings, stateValueType, callbacks); + } + } + + private readonly isSimulated: boolean; + private readonly dataConverter: RpcDataToStateConverter; + + protected constructor(protected ctx: WidgetContext, + protected settings: RpcInitialStateSettings, + protected stateValueType: ValueType, + protected callbacks: RpcStateCallbacks) { + this.isSimulated = this.ctx.$injector.get(UtilsService).widgetEditMode; + if (this.settings.action !== RpcInitialStateAction.DO_NOTHING) { + this.dataConverter = new RpcDataToStateConverter(settings.dataToState, stateValueType, this.callbacks); + } + } + + initState(): Observable { + const stateObservable: Observable = this.isSimulated ? of(null).pipe(delay(500)) : this.doGetState(); + return stateObservable.pipe( + map((data) => { + if (this.dataConverter) { + return this.dataConverter.dataToStateValue(data); + } else { + return data; + } + }) + ); + } + + protected abstract doGetState(): Observable; +} + +type RpcStateToParamsFunction = (state: V) => any; + +export class RpcStateToParamsConverter { + + private readonly constantValue: any; + private readonly stateToParamsFunction: RpcStateToParamsFunction; + + constructor(protected settings: RpcStateToParamsSettings) { + switch (settings.type) { + case RpcStateToParamsType.CONSTANT: + this.constantValue = this.settings.constantValue; + break; + case RpcStateToParamsType.FUNCTION: + try { + this.stateToParamsFunction = new Function('value', settings.stateToParamsFunction) as RpcStateToParamsFunction; + } catch (e) { + this.stateToParamsFunction = (data) => data; + } + break; + case RpcStateToParamsType.NONE: + break; + } + } + + stateToParams(state: V): any { + switch (this.settings.type) { + case RpcStateToParamsType.CONSTANT: + return this.constantValue; + case RpcStateToParamsType.FUNCTION: + let result = state; + try { + result = this.stateToParamsFunction(state); + } catch (e) {} + return result; + case RpcStateToParamsType.NONE: + return null; + } + } +} + +export abstract class RpcStateUpdater { + + static fromSettings(ctx: WidgetContext, + settings: RpcUpdateStateSettings): RpcStateUpdater { + switch (settings.action) { + case RpcUpdateStateAction.EXECUTE_RPC: + return new ExecuteRpcStateUpdater(ctx, settings); + case RpcUpdateStateAction.SET_ATTRIBUTE: + return new RpcAttributeStateUpdater(ctx, settings); + case RpcUpdateStateAction.ADD_TIME_SERIES: + return new RpcTimeSeriesStateUpdater(ctx, settings); + } + } + + private readonly isSimulated: boolean; + private readonly paramsConverter: RpcStateToParamsConverter; + + protected constructor(protected ctx: WidgetContext, + protected settings: RpcUpdateStateSettings) { + this.isSimulated = this.ctx.$injector.get(UtilsService).widgetEditMode; + this.paramsConverter = new RpcStateToParamsConverter(settings.stateToParams); + } + + updateState(state: V): Observable { + if (this.isSimulated) { + return of(null).pipe(delay(500)); + } else { + return this.doUpdateState(this.paramsConverter.stateToParams(state)); + } + } + + protected abstract doUpdateState(params: any): Observable; +} + +export class RpcDefaultStateGetter extends RpcInitialStateGetter { + + private readonly defaultValue: V; + + constructor(protected ctx: WidgetContext, + protected settings: RpcInitialStateSettings, + protected stateValueType: ValueType, + protected callbacks: RpcStateCallbacks) { + super(ctx, settings, stateValueType, callbacks); + this.defaultValue = settings.defaultValue; + } + + protected doGetState(): Observable { + return of(this.defaultValue); + } +} + +export class ExecuteRpcStateGetter extends RpcInitialStateGetter { + + private readonly executeRpcSettings: RpcSettings; + + constructor(protected ctx: WidgetContext, + protected settings: RpcInitialStateSettings, + protected stateValueType: ValueType, + protected callbacks: RpcStateCallbacks) { + super(ctx, settings, stateValueType, callbacks); + this.executeRpcSettings = settings.executeRpc; + } + + protected doGetState(): Observable { + return this.ctx.controlApi.sendTwoWayCommand(this.executeRpcSettings.method, null, + this.executeRpcSettings.requestTimeout, + this.executeRpcSettings.requestPersistent, + this.executeRpcSettings.persistentPollingInterval).pipe( + catchError(() => { + throw new Error(this.ctx.defaultSubscription.rpcErrorText); + }) + ); + } +} + +export class RpcAttributeStateGetter extends RpcInitialStateGetter { + + private readonly getAttributeSettings: RpcGetAttributeSettings; + + constructor(protected ctx: WidgetContext, + protected settings: RpcInitialStateSettings, + protected stateValueType: ValueType, + protected callbacks: RpcStateCallbacks) { + super(ctx, settings, stateValueType, callbacks); + this.getAttributeSettings = settings.getAttribute; + } + + protected doGetState(): Observable { + if (this.ctx.defaultSubscription.targetEntityId) { + return this.ctx.attributeService.getEntityAttributes(this.ctx.defaultSubscription.targetEntityId, + this.getAttributeSettings.scope, [this.getAttributeSettings.key], {ignoreLoading: true, ignoreErrors: true}) + .pipe( + map((data) => data.find(attr => attr.key === this.getAttributeSettings.key)?.value) + ); + } else { + return of(null); + } + } + +} + +export class RpcTimeSeriesStateGetter extends RpcInitialStateGetter { + + private readonly getTimeSeriesSettings: RpcTelemetrySettings; + + constructor(protected ctx: WidgetContext, + protected settings: RpcInitialStateSettings, + protected stateValueType: ValueType, + protected callbacks: RpcStateCallbacks) { + super(ctx, settings, stateValueType, callbacks); + this.getTimeSeriesSettings = settings.getTimeSeries; + } + + protected doGetState(): Observable { + if (this.ctx.defaultSubscription.targetEntityId) { + return this.ctx.attributeService.getEntityTimeseriesLatest(this.ctx.defaultSubscription.targetEntityId, + [this.getTimeSeriesSettings.key], true, {ignoreLoading: true, ignoreErrors: true}) + .pipe( + map((data) => { + let value: any = null; + if (data[this.getTimeSeriesSettings.key]) { + const dataSet = data[this.getTimeSeriesSettings.key]; + if (dataSet.length) { + value = dataSet[0].value; + } + } + return value; + }) + ); + } else { + return of(null); + } + } + +} + +export class ExecuteRpcStateUpdater extends RpcStateUpdater { + + private readonly executeRpcSettings: RpcSettings; + + constructor(protected ctx: WidgetContext, + protected settings: RpcUpdateStateSettings) { + super(ctx, settings); + this.executeRpcSettings = settings.executeRpc; + } + + protected doUpdateState(params: any): Observable { + return this.ctx.controlApi.sendOneWayCommand(this.executeRpcSettings.method, params, + this.executeRpcSettings.requestTimeout, + this.executeRpcSettings.requestPersistent, + this.executeRpcSettings.persistentPollingInterval).pipe( + catchError(() => { + throw new Error(this.ctx.defaultSubscription.rpcErrorText); + }) + ); + } +} + +export class RpcAttributeStateUpdater extends RpcStateUpdater { + + private readonly setAttributeSettings: RpcSetAttributeSettings; + + constructor(protected ctx: WidgetContext, + protected settings: RpcUpdateStateSettings) { + super(ctx, settings); + this.setAttributeSettings = settings.setAttribute; + } + + protected doUpdateState(params: any): Observable { + if (this.ctx.defaultSubscription.targetEntityId) { + const attributes: Array = [{key: this.setAttributeSettings.key, value: params}]; + return this.ctx.attributeService.saveEntityAttributes(this.ctx.defaultSubscription.targetEntityId, + this.setAttributeSettings.scope, attributes, {ignoreLoading: true, ignoreErrors: true}); + } else { + return of(null); + } + } + +} + +export class RpcTimeSeriesStateUpdater extends RpcStateUpdater { + + private readonly putTimeSeriesSettings: RpcTelemetrySettings; + + constructor(protected ctx: WidgetContext, + protected settings: RpcUpdateStateSettings) { + super(ctx, settings); + this.putTimeSeriesSettings = settings.putTimeSeries; + } + + protected doUpdateState(params: any): Observable { + if (this.ctx.defaultSubscription.targetEntityId) { + const timeSeries: Array = [{key: this.putTimeSeriesSettings.key, value: params}]; + return this.ctx.attributeService.saveEntityTimeseries(this.ctx.defaultSubscription.targetEntityId, + LatestTelemetry.LATEST_TELEMETRY, timeSeries, {ignoreLoading: true, ignoreErrors: true}); + } else { + return of(null); + } + } + +} + +const parseError = (ctx: WidgetContext, err: any): string => + ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html new file mode 100644 index 0000000000..62e6296163 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html @@ -0,0 +1,42 @@ + +
+
+
+ +
+
+
+ {{ icon }} +
{{ label$ | async }}
+
+
+
{{ offLabel }}
+ + +
{{ onLabel }}
+
+
+ +
+
+
{{ error }}
+ +
+
+
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..15d1e2b0fe --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.scss @@ -0,0 +1,204 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +$tumblerColorOn: var(--tb-single-switch-tumbler-color-on, #fff); +$tumblerColorOff: var(--tb-single-switch-tumbler-color-off, #fff); +$tumblerColorDisabled: var(--tb-single-switch-tumbler-color-disabled, #fff); + +$switchColorOn: var(--tb-single-switch-color-on, #5469FF); +$switchColorOff: var(--tb-single-switch-color-off, rgba(84, 105, 255, 0.30)); +$switchColorDisabled: var(--tb-single-switch-color-disabled, #D5D7E5); + +.tb-single-switch-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 18px 24px; + > div:not(.tb-single-switch-overlay), > tb-icon { + z-index: 1; + } + .tb-single-switch-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + .tb-single-switch-progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + } + .tb-single-switch-error-container { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + .tb-single-switch-error-panel { + display: flex; + padding: 4px 4px 4px 12px; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 4px; + border: 1px solid rgba(209, 39, 48, 0.04); + background: rgba(209, 39, 48, 0.04); + .tb-single-switch-error-text { + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + color: #D12730; + } + .tb-single-switch-error-clear { + color: rgba(209, 39, 48, 1); + } + } + } + > div.tb-single-switch-title-panel { + position: absolute; + top: 12px; + left: 12px; + right: 12px; + z-index: 2; + } + .tb-single-switch-content { + width: 100%; + height: 100%; + position: relative; + display: flex; + align-items: center; + gap: 8px; + &.right { + flex-direction: row; + justify-content: space-between; + } + &.no-label.right { + justify-content: flex-end; + } + &.left { + flex-direction: row-reverse; + justify-content: flex-end; + } + &.centered { + flex-direction: row; + justify-content: center; + } + .tb-single-switch-label-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + .tb-single-switch-label { + white-space: nowrap; + } + } + &:not(.auto-scale) { + .tb-single-switch-label-row { + overflow: hidden; + .tb-single-switch-label { + overflow: hidden; + text-overflow: ellipsis; + } + } + } + .tb-single-switch-slide-toggle-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + .mat-mdc-slide-toggle { + &.tb-single-switch-toggle { + .mdc-switch { + width: 42px; + height: 22px; + .mdc-switch__track { + height: 22px; + border-radius: 11px; + &::after, &::before { + border: none; + } + } + .mdc-switch__handle-track { + width: calc(100% - 22px); + left: 2px; + } + .mdc-switch__handle { + border-radius: 9px; + width: 18px; + height: 18px; + } + .mdc-switch__ripple { + width: 36px; + height: 36px; + } + .mdc-switch__icons { + display: none; + } + &:disabled { + .mdc-switch__track { + opacity: 1; + &::after, &::before { + background: $switchColorDisabled; + } + } + .mdc-switch__handle::after { + opacity: 1; + background: $tumblerColorDisabled; + } + } + &:enabled, &:enabled:hover { + .mdc-switch__track { + &::after { + background: $switchColorOn; + } + &::before { + background: $switchColorOff; + } + } + &.mdc-switch--selected { + .mdc-switch__handle::after { + background: $tumblerColorOn; + } + .mdc-switch__ripple::after { + background-color: $switchColorOn; + } + } + &.mdc-switch--unselected { + .mdc-switch__handle::after { + background: $tumblerColorOff; + } + .mdc-switch__ripple::after { + background-color: $switchColorOff; + } + } + } + } + .mdc-form-field > label { + display: none; + } + } + } + } +} 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..0fffd67b9f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts @@ -0,0 +1,197 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + OnInit, + Renderer2, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { BasicRpcStateWidgetComponent } from '@home/components/widget/lib/rpc/rpc-widget.models'; +import { + singleSwitchDefaultSettings, + SingleSwitchLayout, + SingleSwitchWidgetSettings +} from '@home/components/widget/lib/rpc/single-switch-widget.models'; +import { ComponentStyle, iconStyle, textStyle } from '@shared/models/widget-settings.models'; +import { Observable } from 'rxjs'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import cssjs from '@core/css/css'; +import { hashCode } from '@core/utils'; +import { RpcUpdateStateSettings } from '@shared/models/rpc-widget-settings.models'; +import { ValueType } from '@shared/models/constants'; + +const horizontalLayoutPadding = 48; +const verticalLayoutPadding = 36; + +@Component({ + selector: 'tb-single-switch-widget', + templateUrl: './single-switch-widget.component.html', + styleUrls: ['./single-switch-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SingleSwitchWidgetComponent extends + BasicRpcStateWidgetComponent 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; + + layout: SingleSwitchLayout; + + showIcon = false; + icon = ''; + iconStyle: ComponentStyle = {}; + + showLabel = true; + label$: Observable; + labelStyle: ComponentStyle = {}; + + showOnLabel = false; + onLabel = ''; + onLabelStyle: ComponentStyle = {}; + + showOffLabel = false; + offLabel = ''; + offLabelStyle: ComponentStyle = {}; + + autoScale = false; + + private panelResize$: ResizeObserver; + + constructor(protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + private renderer: Renderer2, + protected cd: ChangeDetectorRef, + private elementRef: ElementRef) { + super(imagePipe, sanitizer, cd); + } + + ngOnInit(): void { + super.ngOnInit(); + this.layout = this.settings.layout; + + this.autoScale = this.settings.autoScale; + + this.showLabel = this.settings.showLabel; + this.label$ = this.ctx.registerLabelPattern(this.settings.label, this.label$); + this.labelStyle = textStyle(this.settings.labelFont); + this.labelStyle.color = this.settings.labelColor; + + this.showIcon = this.settings.showIcon; + this.icon = this.settings.icon; + this.iconStyle = iconStyle(this.settings.iconSize, this.settings.iconSizeUnit ); + this.iconStyle.color = this.settings.iconColor; + + this.showOnLabel = this.settings.showOnLabel; + this.onLabel = this.settings.onLabel; + this.onLabelStyle = textStyle(this.settings.onLabelFont); + this.onLabelStyle.color = this.settings.onLabelColor; + + this.showOffLabel = this.settings.showOffLabel; + this.offLabel = this.settings.offLabel; + this.offLabelStyle = textStyle(this.settings.offLabelFont); + this.offLabelStyle.color = this.settings.offLabelColor; + const switchVariablesCss = `.tb-single-switch-panel {\n`+ + `--tb-single-switch-tumbler-color-on: ${this.settings.tumblerColorOn};\n`+ + `--tb-single-switch-tumbler-color-off: ${this.settings.tumblerColorOff};\n`+ + `--tb-single-switch-tumbler-color-disabled: ${this.settings.tumblerColorDisabled};\n`+ + `--tb-single-switch-color-on: ${this.settings.switchColorOn};\n`+ + `--tb-single-switch-color-off: ${this.settings.switchColorOff};\n`+ + `--tb-single-switch-color-disabled: ${this.settings.switchColorDisabled};\n`+ + `}`; + const cssParser = new cssjs(); + cssParser.testMode = false; + const namespace = 'single-switch-' + hashCode(switchVariablesCss); + cssParser.cssPreviewNamespace = namespace; + cssParser.createStyleElement(namespace, switchVariablesCss); + this.renderer.addClass(this.elementRef.nativeElement, namespace); + } + + ngAfterViewInit(): void { + if (this.autoScale) { + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'overflow', 'visible'); + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'position', 'absolute'); + this.panelResize$ = new ResizeObserver(() => { + this.onResize(); + }); + this.panelResize$.observe(this.singleSwitchPanel.nativeElement); + if (this.showLabel) { + this.panelResize$.observe(this.singleSwitchLabelRow.nativeElement); + } + this.onResize(); + } + super.ngAfterViewInit(); + } + + ngOnDestroy() { + if (this.panelResize$) { + this.panelResize$.disconnect(); + } + } + + protected stateValueType(): ValueType { + return ValueType.BOOLEAN; + } + + protected defaultValue(): boolean { + return false; + } + + protected defaultSettings(): SingleSwitchWidgetSettings { + return {...singleSwitchDefaultSettings}; + } + + protected getUpdateStateSettingsForValue(value: boolean): RpcUpdateStateSettings { + return value ? this.settings.onUpdateState : this.settings.offUpdateState; + } + + protected validateValue(value: any): boolean { + return !!value; + } + + private onResize() { + const panelWidth = this.singleSwitchPanel.nativeElement.getBoundingClientRect().width - horizontalLayoutPadding; + const panelHeight = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height - verticalLayoutPadding; + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(1)`); + let contentWidth = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().width; + let contentHeight = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().height; + if (this.showIcon || this.showLabel) { + contentWidth += (8 + this.singleSwitchLabelRow.nativeElement.getBoundingClientRect().width); + contentHeight = Math.max(contentHeight, this.singleSwitchLabelRow.nativeElement.getBoundingClientRect().height); + } + const scale = Math.min(panelWidth / contentWidth, panelHeight / contentHeight); + const width = panelWidth / scale; + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'width', width + 'px'); + this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(${scale})`); + } + +} 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..57c2af3b5e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts @@ -0,0 +1,202 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { + RpcDataToStateType, + RpcInitialStateAction, + RpcStateToParamsType, + RpcStateWidgetSettings, + RpcUpdateStateAction, + RpcUpdateStateSettings +} from '@shared/models/rpc-widget-settings.models'; + +export enum SingleSwitchLayout { + right = 'right', + left = 'left', + centered = 'centered' +} + +export const singleSwitchLayouts = Object.keys(SingleSwitchLayout) as SingleSwitchLayout[]; + +export const singleSwitchLayoutTranslations = new Map( + [ + [SingleSwitchLayout.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 extends RpcStateWidgetSettings { + onUpdateState: RpcUpdateStateSettings; + offUpdateState: RpcUpdateStateSettings; + layout: SingleSwitchLayout; + autoScale: boolean; + showLabel: boolean; + label: string; + labelFont: Font; + labelColor: string; + showIcon: boolean; + icon: string; + iconSize: number; + iconSizeUnit: cssUnit; + iconColor: string; + switchColorOn: string; + switchColorOff: string; + switchColorDisabled: string; + tumblerColorOn: string; + tumblerColorOff: string; + tumblerColorDisabled: string; + showOnLabel: boolean; + onLabel: string; + onLabelFont: Font; + onLabelColor: string; + showOffLabel: boolean; + offLabel: string; + offLabelFont: Font; + offLabelColor: string; +} + +export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = { + initialState: { + action: RpcInitialStateAction.EXECUTE_RPC, + defaultValue: false, + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToState: { + type: RpcDataToStateType.NONE, + compareToValue: true, + dataToStateFunction: '/* Should return boolean value */\nreturn data;' + } + }, + onUpdateState: { + action: RpcUpdateStateAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SHARED_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + stateToParams: { + type: RpcStateToParamsType.CONSTANT, + constantValue: true, + stateToParamsFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' + } + }, + offUpdateState: { + action: RpcUpdateStateAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SHARED_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + stateToParams: { + type: RpcStateToParamsType.CONSTANT, + constantValue: false, + stateToParamsFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */ \n return value;' + } + }, + layout: SingleSwitchLayout.right, + autoScale: true, + showLabel: true, + label: 'Switch', + labelFont: { + family: 'Roboto', + size: 16, + sizeUnit: 'px', + style: 'normal', + weight: '500', + lineHeight: '24px' + }, + labelColor: 'rgba(0, 0, 0, 0.76)', + showIcon: false, + icon: 'mdi:lightbulb-outline', + iconSize: 24, + iconSizeUnit: 'px', + iconColor: 'rgba(0, 0, 0, 0.76)', + switchColorOn: '#5469FF', + switchColorOff: 'rgba(84, 105, 255, 0.30)', + switchColorDisabled: '#D5D7E5', + tumblerColorOn: '#fff', + tumblerColorOff: '#fff', + tumblerColorDisabled: '#fff', + showOnLabel: false, + onLabel: 'On', + onLabelFont: { + family: 'Roboto', + size: 16, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '24px' + }, + onLabelColor: 'rgba(0, 0, 0, 0.38)', + showOffLabel: false, + offLabel: 'Off', + offLabelFont: { + family: 'Roboto', + size: 16, + sizeUnit: 'px', + style: 'normal', + weight: '400', + lineHeight: '24px' + }, + offLabelColor: 'rgba(0, 0, 0, 0.38)', + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + } +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.html new file mode 100644 index 0000000000..e290225f08 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.html @@ -0,0 +1,211 @@ + +
+
widgets.rpc-state.initial-state
+
+
+
{{ 'widgets.rpc-state.action' | translate }}
+ + + + {{ rpcInitialStateTranslationsMap.get(action) | translate }} + + + +
+ + +
+
widgets.rpc-state.value
+ +
+
+ +
+
{{ 'widgets.rpc-state.method' | translate }}*
+ + + + warning + + +
+
+ + +
+
{{ 'widgets.rpc-state.attribute-scope' | translate }}
+ + + + {{ 'attribute.scope-any' | translate }} + + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + + +
+
+
{{ 'widgets.rpc-state.attribute-key' | translate }}*
+ + +
+
+
+ + +
+
{{ 'widgets.rpc-state.time-series-key' | translate }}*
+ + +
+
+
+
+
+
+
widgets.rpc-state.action-result-converter
+ + {{ 'widgets.rpc-state.converter-none' | translate }} + {{ 'widgets.rpc-state.converter-function' | translate }} + +
+ + +
+
widgets.rpc-state.on-when-result-is
+ + +
+
+
+ + + + + + widget-config.advanced-settings + + + +
+
{{ 'widgets.rpc-state.request-timeout-ms' | translate }}
+ + + + warning + + +
+
+ + + + + {{ 'widgets.rpc-state.request-persistent' | translate }} + + + + +
+
{{ 'widgets.rpc-state.persistent-polling-interval' | translate }}
+ + + + warning + + +
+
+
+
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.ts new file mode 100644 index 0000000000..b9f5f9b602 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component.ts @@ -0,0 +1,176 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge } from 'rxjs'; +import { + RpcDataToStateType, + RpcInitialStateAction, + rpcInitialStateActions, + RpcInitialStateSettings, + rpcInitialStateTranslations +} from '@shared/models/rpc-widget-settings.models'; +import { ValueType } from '@shared/models/constants'; +import { TargetDevice } from '@shared/models/widget.models'; +import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-rpc-initial-state-settings-panel', + templateUrl: './rpc-initial-state-settings-panel.component.html', + providers: [], + styleUrls: ['./rpc-state-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class RpcInitialStateSettingsPanelComponent extends PageComponent implements OnInit { + + @Input() + initialState: RpcInitialStateSettings; + + @Input() + stateValueType: ValueType; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + popover: TbPopoverComponent; + + @Output() + initialStateSettingsApplied = new EventEmitter>(); + + rpcInitialStateAction = RpcInitialStateAction; + + rpcInitialStateActions = rpcInitialStateActions; + + rpcInitialStateTranslationsMap = rpcInitialStateTranslations; + + telemetryTypeTranslationsMap = telemetryTypeTranslationsShort; + + attributeScopes = Object.keys(AttributeScope) as AttributeScope[]; + + dataKeyType = DataKeyType; + + dataToStateType = RpcDataToStateType; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + valueType = ValueType; + + initialStateSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.initialStateSettingsFormGroup = this.fb.group( + { + action: [this.initialState?.action, []], + defaultValue: [this.initialState?.defaultValue, [Validators.required]], + executeRpc: this.fb.group({ + method: [this.initialState?.executeRpc?.method, [Validators.required]], + requestTimeout: [this.initialState?.executeRpc?.requestTimeout, [Validators.required, Validators.min(5000)]], + requestPersistent: [this.initialState?.executeRpc?.requestPersistent, []], + persistentPollingInterval: [this.initialState?.executeRpc?.persistentPollingInterval, [Validators.required, Validators.min(1000)]] + }), + getAttribute: this.fb.group({ + scope: [this.initialState?.getAttribute?.scope, []], + key: [this.initialState?.getAttribute?.key, [Validators.required]], + }), + getTimeSeries: this.fb.group({ + key: [this.initialState?.getTimeSeries?.key, [Validators.required]], + }), + dataToState: this.fb.group({ + type: [this.initialState?.dataToState?.type, [Validators.required]], + dataToStateFunction: [this.initialState?.dataToState?.dataToStateFunction, [Validators.required]], + }), + } + ); + if (this.stateValueType === ValueType.BOOLEAN) { + (this.initialStateSettingsFormGroup.get('dataToState') as UntypedFormGroup).addControl( + 'compareToValue', this.fb.control(this.initialState?.dataToState?.compareToValue, [Validators.required]) + ); + } + + merge(this.initialStateSettingsFormGroup.get('action').valueChanges, + this.initialStateSettingsFormGroup.get('dataToState').get('type').valueChanges, + this.initialStateSettingsFormGroup.get('executeRpc').get('requestPersistent').valueChanges).subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyInitialStateSettings() { + const initialStateSettings: RpcInitialStateSettings = this.initialStateSettingsFormGroup.getRawValue(); + this.initialStateSettingsApplied.emit(initialStateSettings); + } + + private updateValidators() { + const action: RpcInitialStateAction = this.initialStateSettingsFormGroup.get('action').value; + const dataToStateType: RpcDataToStateType = this.initialStateSettingsFormGroup.get('dataToState').get('type').value; + + this.initialStateSettingsFormGroup.get('defaultValue').disable({emitEvent: false}); + this.initialStateSettingsFormGroup.get('executeRpc').disable({emitEvent: false}); + this.initialStateSettingsFormGroup.get('getAttribute').disable({emitEvent: false}); + this.initialStateSettingsFormGroup.get('getTimeSeries').disable({emitEvent: false}); + switch (action) { + case RpcInitialStateAction.DO_NOTHING: + this.initialStateSettingsFormGroup.get('defaultValue').enable({emitEvent: false}); + break; + case RpcInitialStateAction.EXECUTE_RPC: + this.initialStateSettingsFormGroup.get('executeRpc').enable({emitEvent: false}); + const requestPersistent: boolean = this.initialStateSettingsFormGroup.get('executeRpc').get('requestPersistent').value; + if (requestPersistent) { + this.initialStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').enable({emitEvent: false}); + } else { + this.initialStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').disable({emitEvent: false}); + } + break; + case RpcInitialStateAction.GET_ATTRIBUTE: + this.initialStateSettingsFormGroup.get('getAttribute').enable({emitEvent: false}); + break; + case RpcInitialStateAction.GET_TIME_SERIES: + this.initialStateSettingsFormGroup.get('getTimeSeries').enable({emitEvent: false}); + break; + } + if (action === RpcInitialStateAction.DO_NOTHING) { + this.initialStateSettingsFormGroup.get('dataToState').disable({emitEvent: false}); + } else { + this.initialStateSettingsFormGroup.get('dataToState').enable({emitEvent: false}); + if (dataToStateType === RpcDataToStateType.FUNCTION) { + this.initialStateSettingsFormGroup.get('dataToState').get('dataToStateFunction').enable({emitEvent: false}); + } else { + this.initialStateSettingsFormGroup.get('dataToState').get('dataToStateFunction').disable({emitEvent: false}); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings.component.ts new file mode 100644 index 0000000000..61c053d43c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings.component.ts @@ -0,0 +1,156 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + forwardRef, + HostBinding, + Input, + OnInit, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { RpcInitialStateAction, RpcInitialStateSettings } from '@shared/models/rpc-widget-settings.models'; +import { TranslateService } from '@ngx-translate/core'; +import { ValueType } from '@shared/models/constants'; +import { + RpcInitialStateSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component'; +import { IAliasController } from '@core/api/widget-api.models'; +import { TargetDevice } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-rpc-initial-state-settings', + templateUrl: './rpc-state-settings-button.component.html', + styleUrls: ['./rpc-state-settings-button.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RpcInitialStateSettingsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class RpcInitialStateSettingsComponent implements OnInit, ControlValueAccessor { + + @HostBinding('style.overflow') + overflow = 'hidden'; + + @Input() + stateValueType: ValueType; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + disabled = false; + + modelValue: RpcInitialStateSettings; + + displayValue: string; + + private propagateChange = null; + + constructor(private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) {} + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + if (this.disabled !== isDisabled) { + this.disabled = isDisabled; + } + } + + writeValue(value: RpcInitialStateSettings): void { + this.modelValue = value; + this.updateDisplayValue(); + } + + openRpcStateSettingsPopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + initialState: this.modelValue, + stateValueType: this.stateValueType, + aliasController: this.aliasController, + targetDevice: this.targetDevice + }; + const initialStateSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, RpcInitialStateSettingsPanelComponent, + ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + initialStateSettingsPanelPopover.tbComponentRef.instance.popover = initialStateSettingsPanelPopover; + initialStateSettingsPanelPopover.tbComponentRef.instance.initialStateSettingsApplied.subscribe((initialState) => { + initialStateSettingsPanelPopover.hide(); + this.modelValue = initialState; + this.updateDisplayValue(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateDisplayValue() { + switch (this.modelValue.action) { + case RpcInitialStateAction.DO_NOTHING: + if (this.stateValueType === ValueType.BOOLEAN) { + this.displayValue = this.translate.instant(!!this.modelValue.defaultValue ? 'widgets.rpc-state.on' : 'widgets.rpc-state.off'); + } else { + this.displayValue = this.modelValue.defaultValue + ''; + } + break; + case RpcInitialStateAction.EXECUTE_RPC: + const methodName = this.modelValue.executeRpc.method; + this.displayValue = this.translate.instant('widgets.rpc-state.execute-rpc-text', {methodName}); + break; + case RpcInitialStateAction.GET_ATTRIBUTE: + this.displayValue = this.translate.instant('widgets.rpc-state.get-attribute-text', {key: this.modelValue.getAttribute.key}); + break; + case RpcInitialStateAction.GET_TIME_SERIES: + this.displayValue = this.translate.instant('widgets.rpc-state.get-time-series-text', {key: this.modelValue.getTimeSeries.key}); + break; + } + this.cd.markForCheck(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.component.html new file mode 100644 index 0000000000..da24002e31 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.component.html @@ -0,0 +1,26 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.scss new file mode 100644 index 0000000000..1a6d454152 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-button.scss @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.mdc-button.tb-rpc-state-settings { + width: 100%; + .mdc-button__label { + width: 100%; + & > div { + width: 100%; + display: flex; + flex-direction: row; + gap: 8px; + justify-content: space-between; + align-items: center; + & > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-panel.component.scss new file mode 100644 index 0000000000..e2ffae2c84 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-state-settings-panel.component.scss @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-rpc-state-settings-panel { + width: 530px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-rpc-state-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + } + .tb-rpc-state-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-rpc-state-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.html new file mode 100644 index 0000000000..ad70579d1c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.html @@ -0,0 +1,201 @@ + +
+
{{ panelTitle | translate }}
+
+
+
{{ 'widgets.rpc-state.action' | translate }}
+ + + + {{ rpcUpdateStateTranslationsMap.get(action) | translate }} + + + +
+ + +
+
{{ 'widgets.rpc-state.method' | translate }}*
+ + + + warning + + +
+
+ + +
+
{{ 'widgets.rpc-state.attribute-scope' | translate }}
+ + + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + + +
+
+
{{ 'widgets.rpc-state.attribute-key' | translate }}*
+ + +
+
+
+ + +
+
{{ 'widgets.rpc-state.time-series-key' | translate }}*
+ + +
+
+
+
+
+
+
{{ (updateStateSettingsFormGroup.get('action').value === rpcUpdateStateAction.EXECUTE_RPC ? + 'widgets.rpc-state.parameters' : 'widgets.rpc-state.value') | translate }}
+ + {{ 'widgets.rpc-state.converter-constant' | translate }} + {{ 'widgets.rpc-state.converter-function' | translate }} + {{ 'widgets.rpc-state.converter-none' | translate }} + +
+ + + + +
+
+ + + + + + widget-config.advanced-settings + + + +
+
{{ 'widgets.rpc-state.request-timeout-ms' | translate }}
+ + + + warning + + +
+
+ + + + + {{ 'widgets.rpc-state.request-persistent' | translate }} + + + + +
+
{{ 'widgets.rpc-state.persistent-polling-interval' | translate }}
+ + + + warning + + +
+
+
+
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.ts new file mode 100644 index 0000000000..78c5c6f44b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component.ts @@ -0,0 +1,180 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge } from 'rxjs'; +import { + RpcStateToParamsType, + RpcUpdateStateAction, + rpcUpdateStateActions, + RpcUpdateStateSettings, + rpcUpdateStateTranslations +} from '@shared/models/rpc-widget-settings.models'; +import { ValueType } from '@shared/models/constants'; +import { TargetDevice } from '@shared/models/widget.models'; +import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-rpc-update-state-settings-panel', + templateUrl: './rpc-update-state-settings-panel.component.html', + providers: [], + styleUrls: ['./rpc-state-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class RpcUpdateStateSettingsPanelComponent extends PageComponent implements OnInit { + + @Input() + panelTitle: string; + + @Input() + updateState: RpcUpdateStateSettings; + + @Input() + stateValueType: ValueType; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + popover: TbPopoverComponent; + + @Output() + updateStateSettingsApplied = new EventEmitter(); + + rpcUpdateStateAction = RpcUpdateStateAction; + + rpcUpdateStateActions = rpcUpdateStateActions; + + rpcUpdateStateTranslationsMap = rpcUpdateStateTranslations; + + telemetryTypeTranslationsMap = telemetryTypeTranslationsShort; + + attributeScopes = [AttributeScope.SERVER_SCOPE, AttributeScope.SHARED_SCOPE]; + + dataKeyType = DataKeyType; + + stateToParamsType = RpcStateToParamsType; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + valueType = ValueType; + + updateStateSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + protected store: Store) { + super(store); + } + + ngOnInit(): void { + this.updateStateSettingsFormGroup = this.fb.group( + { + action: [this.updateState?.action, []], + executeRpc: this.fb.group({ + method: [this.updateState?.executeRpc?.method, [Validators.required]], + requestTimeout: [this.updateState?.executeRpc?.requestTimeout, [Validators.required, Validators.min(5000)]], + requestPersistent: [this.updateState?.executeRpc?.requestPersistent, []], + persistentPollingInterval: [this.updateState?.executeRpc?.persistentPollingInterval, [Validators.required, Validators.min(1000)]] + }), + setAttribute: this.fb.group({ + scope: [this.updateState?.setAttribute?.scope, []], + key: [this.updateState?.setAttribute?.key, [Validators.required]], + }), + putTimeSeries: this.fb.group({ + key: [this.updateState?.putTimeSeries?.key, [Validators.required]], + }), + stateToParams: this.fb.group({ + type: [this.updateState?.stateToParams?.type, [Validators.required]], + constantValue: [this.updateState?.stateToParams?.constantValue, [Validators.required]], + stateToParamsFunction: [this.updateState?.stateToParams?.stateToParamsFunction, [Validators.required]], + }), + } + ); + + merge(this.updateStateSettingsFormGroup.get('action').valueChanges, + this.updateStateSettingsFormGroup.get('stateToParams').get('type').valueChanges, + this.updateStateSettingsFormGroup.get('executeRpc').get('requestPersistent').valueChanges).subscribe(() => { + this.updateValidators(); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyUpdateStateSettings() { + const updateStateSettings: RpcUpdateStateSettings = this.updateStateSettingsFormGroup.getRawValue(); + this.updateStateSettingsApplied.emit(updateStateSettings); + } + + private updateValidators() { + const action: RpcUpdateStateAction = this.updateStateSettingsFormGroup.get('action').value; + let stateToParamsType: RpcStateToParamsType = this.updateStateSettingsFormGroup.get('stateToParams').get('type').value; + + this.updateStateSettingsFormGroup.get('executeRpc').disable({emitEvent: false}); + this.updateStateSettingsFormGroup.get('setAttribute').disable({emitEvent: false}); + this.updateStateSettingsFormGroup.get('putTimeSeries').disable({emitEvent: false}); + switch (action) { + case RpcUpdateStateAction.EXECUTE_RPC: + this.updateStateSettingsFormGroup.get('executeRpc').enable({emitEvent: false}); + const requestPersistent: boolean = this.updateStateSettingsFormGroup.get('executeRpc').get('requestPersistent').value; + if (requestPersistent) { + this.updateStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').enable({emitEvent: false}); + } else { + this.updateStateSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').disable({emitEvent: false}); + } + break; + case RpcUpdateStateAction.SET_ATTRIBUTE: + case RpcUpdateStateAction.ADD_TIME_SERIES: + if (stateToParamsType === RpcStateToParamsType.NONE) { + stateToParamsType = RpcStateToParamsType.CONSTANT; + this.updateStateSettingsFormGroup.get('stateToParams').get('type').patchValue(stateToParamsType, {emitEvent: false}); + } + if (action === RpcUpdateStateAction.SET_ATTRIBUTE) { + this.updateStateSettingsFormGroup.get('setAttribute').enable({emitEvent: false}); + } else { + this.updateStateSettingsFormGroup.get('putTimeSeries').enable({emitEvent: false}); + } + break; + } + switch (stateToParamsType) { + case RpcStateToParamsType.CONSTANT: + this.updateStateSettingsFormGroup.get('stateToParams').get('constantValue').enable({emitEvent: false}); + this.updateStateSettingsFormGroup.get('stateToParams').get('stateToParamsFunction').disable({emitEvent: false}); + break; + case RpcStateToParamsType.FUNCTION: + this.updateStateSettingsFormGroup.get('stateToParams').get('constantValue').disable({emitEvent: false}); + this.updateStateSettingsFormGroup.get('stateToParams').get('stateToParamsFunction').enable({emitEvent: false}); + break; + case RpcStateToParamsType.NONE: + this.updateStateSettingsFormGroup.get('stateToParams').get('constantValue').disable({emitEvent: false}); + this.updateStateSettingsFormGroup.get('stateToParams').get('stateToParamsFunction').disable({emitEvent: false}); + break; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings.component.ts new file mode 100644 index 0000000000..8a65861783 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/rpc/rpc-update-state-settings.component.ts @@ -0,0 +1,174 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + forwardRef, + HostBinding, + Input, + OnInit, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { + RpcStateToParamsType, + RpcUpdateStateAction, + RpcUpdateStateSettings +} from '@shared/models/rpc-widget-settings.models'; +import { TranslateService } from '@ngx-translate/core'; +import { ValueType } from '@shared/models/constants'; +import { IAliasController } from '@core/api/widget-api.models'; +import { TargetDevice } from '@shared/models/widget.models'; +import { isDefinedAndNotNull } from '@core/utils'; +import { + RpcUpdateStateSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component'; + +@Component({ + selector: 'tb-rpc-update-state-settings', + templateUrl: './rpc-state-settings-button.component.html', + styleUrls: ['./rpc-state-settings-button.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RpcUpdateStateSettingsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class RpcUpdateStateSettingsComponent implements OnInit, ControlValueAccessor { + + @HostBinding('style.overflow') + overflow = 'hidden'; + + @Input() + panelTitle: string; + + @Input() + stateValueType: ValueType; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + disabled = false; + + modelValue: RpcUpdateStateSettings; + + displayValue: string; + + private propagateChange = null; + + constructor(private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) {} + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + if (this.disabled !== isDisabled) { + this.disabled = isDisabled; + } + } + + writeValue(value: RpcUpdateStateSettings): void { + this.modelValue = value; + this.updateDisplayValue(); + } + + openRpcStateSettingsPopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + updateState: this.modelValue, + panelTitle: this.panelTitle, + stateValueType: this.stateValueType, + aliasController: this.aliasController, + targetDevice: this.targetDevice + }; + const updateStateSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, RpcUpdateStateSettingsPanelComponent, + ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + updateStateSettingsPanelPopover.tbComponentRef.instance.popover = updateStateSettingsPanelPopover; + updateStateSettingsPanelPopover.tbComponentRef.instance.updateStateSettingsApplied.subscribe((updateState) => { + updateStateSettingsPanelPopover.hide(); + this.modelValue = updateState; + this.updateDisplayValue(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateDisplayValue() { + let value: any; + switch (this.modelValue.stateToParams.type) { + case RpcStateToParamsType.CONSTANT: + value = this.modelValue.stateToParams.constantValue; + break; + case RpcStateToParamsType.FUNCTION: + value = 'f(value)'; + break; + case RpcStateToParamsType.NONE: + break; + } + switch (this.modelValue.action) { + case RpcUpdateStateAction.EXECUTE_RPC: + let methodName = this.modelValue.executeRpc.method; + if (isDefinedAndNotNull(value)) { + methodName = `${methodName}(${value})`; + } + this.displayValue = this.translate.instant('widgets.rpc-state.execute-rpc-text', {methodName}); + break; + case RpcUpdateStateAction.SET_ATTRIBUTE: + this.displayValue = this.translate.instant('widgets.rpc-state.set-attribute-to-value-text', + {key: this.modelValue.setAttribute.key, value}); + break; + case RpcUpdateStateAction.ADD_TIME_SERIES: + this.displayValue = this.translate.instant('widgets.rpc-state.add-time-series-value-text', + {key: this.modelValue.setAttribute.key, value}); + break; + } + this.cd.markForCheck(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 5eb9d598ff..ba08a39bb7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -51,6 +51,21 @@ import { ColorRangePanelComponent } from '@home/components/widget/lib/settings/c import { ColorRangeSettingsComponent, ColorRangeSettingsComponentService } from '@home/components/widget/lib/settings/common/color-range-settings.component'; +import { + RpcInitialStateSettingsComponent +} from '@home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings.component'; +import { + RpcInitialStateSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/rpc/rpc-initial-state-settings-panel.component'; +import { + DeviceKeyAutocompleteComponent +} from '@home/components/widget/lib/settings/control/device-key-autocomplete.component'; +import { + RpcUpdateStateSettingsComponent +} from '@home/components/widget/lib/settings/common/rpc/rpc-update-state-settings.component'; +import { + RpcUpdateStateSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/rpc/rpc-update-state-settings-panel.component'; @NgModule({ declarations: [ @@ -71,7 +86,12 @@ import { CountWidgetSettingsComponent, ColorRangeListComponent, ColorRangePanelComponent, - ColorRangeSettingsComponent + ColorRangeSettingsComponent, + RpcInitialStateSettingsComponent, + RpcInitialStateSettingsPanelComponent, + DeviceKeyAutocompleteComponent, + RpcUpdateStateSettingsComponent, + RpcUpdateStateSettingsPanelComponent ], imports: [ CommonModule, @@ -96,7 +116,12 @@ import { CountWidgetSettingsComponent, ColorRangeListComponent, ColorRangePanelComponent, - ColorRangeSettingsComponent + ColorRangeSettingsComponent, + RpcInitialStateSettingsComponent, + RpcInitialStateSettingsPanelComponent, + DeviceKeyAutocompleteComponent, + RpcUpdateStateSettingsComponent, + RpcUpdateStateSettingsPanelComponent ], providers: [ ColorSettingsComponentService, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html index 5a2d11d3a5..977a493c5f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html @@ -15,8 +15,13 @@ limitations under the License. --> - - {{ (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 6255fa6e48..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 @@ -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/led-indicator-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html index 669c9a2fce..fa66dd6be1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html @@ -52,7 +52,7 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts index f4c570bf4e..8bf58a9b22 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts @@ -15,7 +15,7 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -36,12 +36,8 @@ export class RoundSwitchWidgetSettingsComponent extends WidgetSettingsComponent super(store); } - get targetDeviceAliasId(): string { - const aliasIds = this.widget?.config?.targetDeviceAliasIds; - if (aliasIds && aliasIds.length) { - return aliasIds[0]; - } - return null; + get targetDevice(): TargetDevice { + return this.widget?.config?.targetDevice; } protected settingsForm(): UntypedFormGroup { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html new file mode 100644 index 0000000000..1006d65f55 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html @@ -0,0 +1,198 @@ + + +
+
widgets.single-switch.behavior
+
+
widgets.rpc-state.initial-state
+ +
+
+
widgets.rpc-state.turn-on
+ +
+
+
widgets.rpc-state.turn-off
+ +
+
+
+
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..7e4f1d1d96 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts @@ -0,0 +1,157 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + singleSwitchDefaultSettings, singleSwitchLayoutImages, + singleSwitchLayouts, + singleSwitchLayoutTranslations +} from '@home/components/widget/lib/rpc/single-switch-widget.models'; +import { ValueType } from '@shared/models/constants'; + +@Component({ + selector: 'tb-single-switch-widget-settings', + templateUrl: './single-switch-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class SingleSwitchWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + return this.widget?.config?.targetDevice; + } + + singleSwitchLayouts = singleSwitchLayouts; + + singleSwitchLayoutTranslationMap = singleSwitchLayoutTranslations; + singleSwitchLayoutImageMap = singleSwitchLayoutImages; + + valueType = ValueType; + + singleSwitchWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.singleSwitchWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...singleSwitchDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.singleSwitchWidgetSettingsForm = this.fb.group({ + initialState: [settings.initialState, []], + onUpdateState: [settings.onUpdateState, []], + offUpdateState: [settings.offUpdateState, []], + layout: [settings.layout, []], + autoScale: [settings.autoScale, []], + + showLabel: [settings.showLabel, []], + label: [settings.label, []], + labelFont: [settings.labelFont, []], + labelColor: [settings.labelColor, []], + + showIcon: [settings.showIcon, []], + iconSize: [settings.iconSize, [Validators.min(0)]], + iconSizeUnit: [settings.iconSizeUnit, []], + icon: [settings.icon, []], + iconColor: [settings.iconColor, []], + + switchColorOn: [settings.switchColorOn, []], + switchColorOff: [settings.switchColorOff, []], + switchColorDisabled: [settings.switchColorDisabled, []], + + tumblerColorOn: [settings.tumblerColorOn, []], + tumblerColorOff: [settings.tumblerColorOff, []], + tumblerColorDisabled: [settings.tumblerColorDisabled, []], + + showOnLabel: [settings.showOnLabel, []], + onLabel: [settings.onLabel, []], + onLabelFont: [settings.onLabelFont, []], + onLabelColor: [settings.onLabelColor, []], + + showOffLabel: [settings.showOffLabel, []], + offLabel: [settings.offLabel, []], + offLabelFont: [settings.offLabelFont, []], + offLabelColor: [settings.offLabelColor, []], + + background: [settings.background, []] + }); + } + + protected validatorTriggers(): string[] { + return ['showLabel', 'showIcon', 'showOnLabel', 'showOffLabel']; + } + + protected updateValidators(emitEvent: boolean): void { + const showLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showLabel').value; + const showIcon: boolean = this.singleSwitchWidgetSettingsForm.get('showIcon').value; + const showOnLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showOnLabel').value; + const showOffLabel: boolean = this.singleSwitchWidgetSettingsForm.get('showOffLabel').value; + + if (showLabel) { + this.singleSwitchWidgetSettingsForm.get('label').enable(); + this.singleSwitchWidgetSettingsForm.get('labelFont').enable(); + this.singleSwitchWidgetSettingsForm.get('labelColor').enable(); + } else { + this.singleSwitchWidgetSettingsForm.get('label').disable(); + this.singleSwitchWidgetSettingsForm.get('labelFont').disable(); + this.singleSwitchWidgetSettingsForm.get('labelColor').disable(); + } + + if (showIcon) { + this.singleSwitchWidgetSettingsForm.get('iconSize').enable(); + this.singleSwitchWidgetSettingsForm.get('iconSizeUnit').enable(); + this.singleSwitchWidgetSettingsForm.get('icon').enable(); + this.singleSwitchWidgetSettingsForm.get('iconColor').enable(); + } else { + this.singleSwitchWidgetSettingsForm.get('iconSize').disable(); + this.singleSwitchWidgetSettingsForm.get('iconSizeUnit').disable(); + this.singleSwitchWidgetSettingsForm.get('icon').disable(); + this.singleSwitchWidgetSettingsForm.get('iconColor').disable(); + } + + if (showOnLabel) { + this.singleSwitchWidgetSettingsForm.get('onLabel').enable(); + this.singleSwitchWidgetSettingsForm.get('onLabelFont').enable(); + this.singleSwitchWidgetSettingsForm.get('onLabelColor').enable(); + } else { + this.singleSwitchWidgetSettingsForm.get('onLabel').disable(); + this.singleSwitchWidgetSettingsForm.get('onLabelFont').disable(); + this.singleSwitchWidgetSettingsForm.get('onLabelColor').disable(); + } + + if (showOffLabel) { + this.singleSwitchWidgetSettingsForm.get('offLabel').enable(); + this.singleSwitchWidgetSettingsForm.get('offLabelFont').enable(); + this.singleSwitchWidgetSettingsForm.get('offLabelColor').enable(); + } else { + this.singleSwitchWidgetSettingsForm.get('offLabel').disable(); + this.singleSwitchWidgetSettingsForm.get('offLabelFont').disable(); + this.singleSwitchWidgetSettingsForm.get('offLabelColor').disable(); + } + } + + protected readonly ValueType = ValueType; +} 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 31a9cc25be..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 @@ -51,7 +51,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.ts index 95f8018db9..d14d0fd594 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.ts @@ -15,7 +15,7 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -36,12 +36,8 @@ export class SlideToggleWidgetSettingsComponent extends WidgetSettingsComponent super(store); } - get targetDeviceAliasId(): string { - const aliasIds = this.widget?.config?.targetDeviceAliasIds; - if (aliasIds && aliasIds.length) { - return aliasIds[0]; - } - return null; + get targetDevice(): TargetDevice { + return this.widget?.config?.targetDevice; } protected settingsForm(): UntypedFormGroup { 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 931f077015..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 @@ -27,7 +27,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts index 9e9c46429b..197fc2d229 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts @@ -15,7 +15,7 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -36,12 +36,8 @@ export class SwitchControlWidgetSettingsComponent extends WidgetSettingsComponen super(store); } - get targetDeviceAliasId(): string { - const aliasIds = this.widget?.config?.targetDeviceAliasIds; - if (aliasIds && aliasIds.length) { - return aliasIds[0]; - } - return null; + get targetDevice(): TargetDevice { + return this.widget?.config?.targetDevice; } protected settingsForm(): UntypedFormGroup { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html index c1d6e4d2c5..12a722c77a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html @@ -44,7 +44,7 @@ [fxShow]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' || switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'timeseries'" [aliasController]="aliasController" - [targetDeviceAliasId]="targetDeviceAliasId" + [targetDevice]="targetDevice" [keyType]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries" required formControlName="valueKey"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts index c069774f8e..764bfcfce9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts @@ -17,11 +17,11 @@ import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; import { ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, Validator, Validators } from '@angular/forms'; @@ -33,6 +33,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { WidgetService } from '@core/http/widget.service'; import { IAliasController } from '@core/api/widget-api.models'; import { EntityService } from '@core/http/entity.service'; +import { TargetDevice } from '@shared/models/widget.models'; export declare type RpcRetrieveValueMethod = 'none' | 'rpc' | 'attribute' | 'timeseries'; @@ -49,8 +50,7 @@ export interface SwitchRpcSettings { persistentPollingInterval: number; } -export function switchRpcDefaultSettings(): SwitchRpcSettings { - return { +export const switchRpcDefaultSettings = (): SwitchRpcSettings => ({ initialValue: false, retrieveValueMethod: 'rpc', valueKey: 'value', @@ -61,8 +61,7 @@ export function switchRpcDefaultSettings(): SwitchRpcSettings { requestTimeout: 500, requestPersistent: false, persistentPollingInterval: 5000 - }; -} + }); @Component({ selector: 'tb-switch-rpc-settings', @@ -92,7 +91,7 @@ export class SwitchRpcSettingsComponent extends PageComponent implements OnInit, aliasController: IAliasController; @Input() - targetDeviceAliasId: string; + targetDevice: TargetDevice; dataKeyType = DataKeyType; @@ -185,8 +184,7 @@ export class SwitchRpcSettingsComponent extends PageComponent implements OnInit, } private updateModel() { - const value: SwitchRpcSettings = this.switchRpcSettingsFormGroup.value; - this.modelValue = value; + this.modelValue = this.switchRpcSettingsFormGroup.value; this.propagateChange(this.modelValue); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index 7b32a1ca4a..30c1b748de 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -123,9 +123,6 @@ import { import { SendRpcWidgetSettingsComponent } from '@home/components/widget/lib/settings/control/send-rpc-widget-settings.component'; -import { - DeviceKeyAutocompleteComponent -} from '@home/components/widget/lib/settings/control/device-key-autocomplete.component'; import { LedIndicatorWidgetSettingsComponent } from '@home/components/widget/lib/settings/control/led-indicator-widget-settings.component'; @@ -311,6 +308,9 @@ import { import { BarChartWithLabelsWidgetSettingsComponent } from '@home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component'; +import { + SingleSwitchWidgetSettingsComponent +} from '@home/components/widget/lib/settings/control/single-switch-widget-settings.component'; @NgModule({ declarations: [ @@ -348,7 +348,6 @@ import { FlotPieKeySettingsComponent, ChartWidgetSettingsComponent, DoughnutChartWidgetSettingsComponent, - DeviceKeyAutocompleteComponent, SwitchRpcSettingsComponent, RoundSwitchWidgetSettingsComponent, SwitchControlWidgetSettingsComponent, @@ -424,7 +423,8 @@ import { LiquidLevelCardWidgetSettingsComponent, DoughnutWidgetSettingsComponent, RangeChartWidgetSettingsComponent, - BarChartWithLabelsWidgetSettingsComponent + BarChartWithLabelsWidgetSettingsComponent, + SingleSwitchWidgetSettingsComponent ], imports: [ CommonModule, @@ -467,7 +467,6 @@ import { FlotPieKeySettingsComponent, ChartWidgetSettingsComponent, DoughnutChartWidgetSettingsComponent, - DeviceKeyAutocompleteComponent, SwitchRpcSettingsComponent, RoundSwitchWidgetSettingsComponent, SwitchControlWidgetSettingsComponent, @@ -543,7 +542,8 @@ import { LiquidLevelCardWidgetSettingsComponent, DoughnutWidgetSettingsComponent, RangeChartWidgetSettingsComponent, - BarChartWithLabelsWidgetSettingsComponent + BarChartWithLabelsWidgetSettingsComponent, + SingleSwitchWidgetSettingsComponent ] }) export class WidgetSettingsModule { @@ -628,5 +628,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type - + widget-config.advanced-title-style @@ -241,21 +241,13 @@ formControlName="datasources"> -
-
widget-config.target-device
-
- - -
-
+ + + +
+ [formGroup]="dataSettings" class="tb-form-panel">
widget-config.alarm-source
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index fbdaa1c822..49e4e2ea59 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -36,6 +36,8 @@ import { GroupInfo, JsonSchema, JsonSettingsSchema, + TargetDevice, + TargetDeviceType, targetDeviceValid, Widget, WidgetConfigMode, widgetType @@ -368,9 +370,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe this.widgetType !== widgetType.static) { this.dataSettings.addControl('datasources', this.fb.control(null)); } else if (this.widgetType === widgetType.rpc) { - this.targetDeviceSettings.addControl('targetDeviceAliasId', - this.fb.control(null, - this.widgetEditMode ? [] : [Validators.required])); + this.targetDeviceSettings.addControl('targetDevice', + this.fb.control(null, [])); } else if (this.widgetType === widgetType.alarm) { this.dataSettings.addControl('alarmSource', this.fb.control(null)); } @@ -534,20 +535,9 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe this.dataSettings.patchValue({ datasources: config.datasources}, {emitEvent: false}); } else if (this.widgetType === widgetType.rpc) { - let targetDeviceAliasId: string; - if (config.targetDeviceAliasIds && config.targetDeviceAliasIds.length > 0) { - const aliasId = config.targetDeviceAliasIds[0]; - const entityAliases = this.aliasController.getEntityAliases(); - if (entityAliases[aliasId]) { - targetDeviceAliasId = entityAliases[aliasId].id; - } else { - targetDeviceAliasId = null; - } - } else { - targetDeviceAliasId = null; - } + const targetDevice: TargetDevice = config.targetDevice; this.targetDeviceSettings.patchValue({ - targetDeviceAliasId + targetDevice }, {emitEvent: false}); } else if (this.widgetType === widgetType.alarm) { this.dataSettings.patchValue( @@ -650,12 +640,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe private updateTargetDeviceSettings() { if (this.modelValue) { if (this.modelValue.config) { - const targetDeviceAliasId: string = this.targetDeviceSettings.get('targetDeviceAliasId').value; - if (targetDeviceAliasId) { - this.modelValue.config.targetDeviceAliasIds = [targetDeviceAliasId]; - } else { - this.modelValue.config.targetDeviceAliasIds = []; - } + this.modelValue.config.targetDevice = this.targetDeviceSettings.get('targetDevice').value; } this.propagateChange(this.modelValue); } @@ -944,9 +929,9 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe if (this.modelValue) { const config = this.modelValue.config; if (this.widgetType === widgetType.rpc && this.modelValue.isDataEnabled) { - if (!this.widgetEditMode && (!config.targetDeviceAliasIds || !config.targetDeviceAliasIds.length)) { + if (!this.widgetEditMode && !targetDeviceValid(config.targetDevice)) { return { - targetDeviceAliasIds: { + targetDevice: { valid: false } }; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 182cd128c9..a7fbc64db7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -19,7 +19,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ComponentFactoryResolver, ComponentRef, ElementRef, Inject, @@ -110,12 +109,12 @@ import { MatDialog } from '@angular/material/dialog'; import { ComponentType } from '@angular/cdk/portal'; import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token'; import { MobileService } from '@core/services/mobile.service'; -import { DialogService } from '@core/services/dialog.service'; import { PopoverPlacement } from '@shared/components/popover.models'; import { TbPopoverService } from '@shared/components/popover.service'; import { DASHBOARD_PAGE_COMPONENT_TOKEN } from '@home/components/tokens'; import { MODULES_MAP } from '@shared/models/constants'; import { IModulesMap } from '@modules/common/modules-map.models'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; @Component({ selector: 'tb-widget', @@ -179,7 +178,6 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI private route: ActivatedRoute, private router: Router, private widgetComponentService: WidgetComponentService, - private componentFactoryResolver: ComponentFactoryResolver, private elementRef: ElementRef, private injector: Injector, private dialog: MatDialog, @@ -198,8 +196,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI private alarmDataService: AlarmDataService, private translate: TranslateService, private utils: UtilsService, + private dashboardUtils: DashboardUtilsService, private mobileService: MobileService, - private dialogs: DialogService, private raf: RafService, private ngZone: NgZone, private cd: ChangeDetectorRef) { @@ -298,6 +296,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.subscriptionContext.entityDataService = this.entityDataService; this.subscriptionContext.alarmDataService = this.alarmDataService; this.subscriptionContext.utils = this.utils; + this.subscriptionContext.dashboardUtils = this.dashboardUtils; this.subscriptionContext.raf = this.raf; this.subscriptionContext.widgetUtils = this.widgetContext.utils; this.subscriptionContext.getServerTimeDiff = this.dashboardService.getServerTimeDiff.bind(this.dashboardService); @@ -959,7 +958,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.loadingData = false; options = { type: this.widget.type, - targetDeviceAliasIds: this.widget.config.targetDeviceAliasIds + targetDevice: this.widget.config.targetDevice }; options.callbacks = { rpcStateChanged: (subscription) => { @@ -974,7 +973,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText; this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection; - this.clearMessage(); + if (this.typeParameters.displayRpcMessageToast) { + this.clearMessage(); + } this.detectChanges(); } }, @@ -983,7 +984,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText; this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection; - if (subscription.rpcErrorText) { + if (subscription.rpcErrorText && this.typeParameters.displayRpcMessageToast) { this.displayMessage('error', subscription.rpcErrorText); } this.detectChanges(); @@ -993,7 +994,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI if (this.dynamicWidgetComponent) { this.dynamicWidgetComponent.rpcErrorText = null; this.dynamicWidgetComponent.rpcRejection = null; - this.clearMessage(); + if (this.typeParameters.displayRpcMessageToast) { + this.clearMessage(); + } this.detectChanges(); } } diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index c23bef5b88..950f951415 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -53,7 +53,13 @@ import { RafService } from '@core/services/raf.service'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; import { TenantId } from '@shared/models/id/tenant-id'; import { WidgetLayout } from '@shared/models/dashboard.models'; -import { createLabelFromDatasource, formatValue, hasDatasourceLabelsVariables, isDefined } from '@core/utils'; +import { + createLabelFromDatasource, + createLabelFromSubscriptionEntityInfo, + formatValue, + hasDatasourceLabelsVariables, + isDefined +} from '@core/utils'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { @@ -482,9 +488,14 @@ export class LabelVariablePattern { update() { let label = this.pattern; - const datasource = this.ctx.defaultSubscription?.firstDatasource; - if (this.hasVariables && datasource) { - label = createLabelFromDatasource(datasource, label); + if (this.hasVariables) { + if (this.ctx.defaultSubscription?.type === widgetType.rpc) { + const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo(); + label = createLabelFromSubscriptionEntityInfo(entityInfo, label); + } else { + const datasource = this.ctx.defaultSubscription?.firstDatasource; + label = createLabelFromDatasource(datasource, label); + } } if (this.labelSubject.value !== label) { this.labelSubject.next(label); diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html index 86d2700929..c56d09abbb 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html index 91d89b6445..11329df694 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html @@ -16,7 +16,7 @@ -->
-
+
@@ -25,8 +25,8 @@ placeholder="{{ 'widget.widget-title' | translate }}"/> - + {{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }} @@ -322,7 +322,7 @@
- +
- - {{ (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 9e335d813c..88355f4459 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.scss +++ b/ui-ngx/src/app/shared/components/value-input.component.scss @@ -13,14 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +:host { + .tb-boolean-input { + min-width: 210px; + } +} + :host ::ng-deep { .mat-mdc-form-field.tb-value-type { + &.row { + width: 140px; + min-width: 140px; + } + &.column { + flex: 1; + width: auto; + } + .mat-mdc-form-field-infix { + max-height: 40px; + } mat-select-trigger { - .mat-icon { - vertical-align: bottom; - margin-right: 16px; - svg { - vertical-align: initial; + .tb-value-type-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 10px; + .mat-icon { + color: rgba(0, 0, 0, 0.38); + vertical-align: bottom; } } } 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 27c65e2288..cce9a2400f 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.ts +++ b/ui-ngx/src/app/shared/components/value-input.component.ts @@ -14,15 +14,24 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgForm } from '@angular/forms'; -import { ValueType, valueTypesMap } from '@shared/models/constants'; +import { resolveBreakpoint, ValueType, valueTypesMap } from '@shared/models/constants'; import { isObject } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { JsonObjectEditDialogComponent, JsonObjectEditDialogData } from '@shared/components/dialog/json-object-edit-dialog.component'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { Subscription } from 'rxjs'; + +type Layout = 'column' | 'row'; + +export interface ValueInputLayout { + layout: Layout; + breakpoints?: {[breakpoint: string]: Layout}; +} @Component({ selector: 'tb-value-input', @@ -36,33 +45,67 @@ import { } ] }) -export class ValueInputComponent implements OnInit, ControlValueAccessor { +export class ValueInputComponent implements OnInit, OnDestroy, ControlValueAccessor { + + @Input() + disabled: boolean; + + @Input() + requiredText: string; + + @Input() + valueType: ValueType; - @Input() disabled: boolean; + @Input() + trueLabel = 'value.true'; - @Input() requiredText: string; + @Input() + falseLabel = 'value.false'; + + @Input() + layout: ValueInputLayout | Layout = 'row'; @ViewChild('inputForm', {static: true}) inputForm: NgForm; modelValue: any; - valueType: ValueType; - public valueTypeEnum = ValueType; valueTypeKeys = Object.keys(ValueType); valueTypes = valueTypesMap; + showValueType = true; + + computedLayout: Layout; + private propagateChange = null; + private _subscription: Subscription; + constructor( + private breakpointObserver: BreakpointObserver, public dialog: MatDialog, ) { } ngOnInit(): void { + this._subscription = new Subscription(); + this.showValueType = !this.valueType; + this.computedLayout = this._computeLayout(); + if (typeof this.layout === 'object' && this.layout.breakpoints) { + const breakpoints = Object.keys(this.layout.breakpoints); + this._subscription.add(this.breakpointObserver.observe(breakpoints.map(breakpoint => resolveBreakpoint(breakpoint))).subscribe( + () => { + this.computedLayout = this._computeLayout(); + } + )); + } + } + + ngOnDestroy() { + this._subscription.unsubscribe(); } openEditJSONDialog($event: Event) { @@ -100,18 +143,20 @@ export class ValueInputComponent implements OnInit, ControlValueAccessor { writeValue(value: any): void { this.modelValue = value; - if (this.modelValue === true || this.modelValue === false) { - this.valueType = ValueType.BOOLEAN; - } else if (typeof this.modelValue === 'number') { - if (this.modelValue.toString().indexOf('.') === -1) { - this.valueType = ValueType.INTEGER; + if (this.showValueType) { + if (this.modelValue === true || this.modelValue === false) { + this.valueType = ValueType.BOOLEAN; + } else if (typeof this.modelValue === 'number') { + if (this.modelValue.toString().indexOf('.') === -1) { + this.valueType = ValueType.INTEGER; + } else { + this.valueType = ValueType.DOUBLE; + } + } else if (isObject(this.modelValue)) { + this.valueType = ValueType.JSON; } else { - this.valueType = ValueType.DOUBLE; + this.valueType = ValueType.STRING; } - } else if (isObject(this.modelValue)) { - this.valueType = ValueType.JSON; - } else { - this.valueType = ValueType.STRING; } } @@ -140,4 +185,22 @@ export class ValueInputComponent implements OnInit, ControlValueAccessor { this.updateView(); } + private _computeLayout(): Layout { + if (typeof this.layout !== 'object') { + return this.layout; + } else { + let layout = this.layout.layout; + if (this.layout.breakpoints) { + for (const breakpoint of Object.keys(this.layout.breakpoints)) { + const breakpointValue = resolveBreakpoint(breakpoint); + if (this.breakpointObserver.isMatched(breakpointValue)) { + layout = this.layout.breakpoints[breakpoint]; + break; + } + } + } + return layout; + } + } + } diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 792b855534..b7d0dd6a25 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -229,11 +229,10 @@ export class ImportExportService { const originalSize = widgetItem.originalSize; const datasourceAliases = aliasesInfo.datasourceAliases; - const targetDeviceAliases = aliasesInfo.targetDeviceAliases; - if (datasourceAliases || targetDeviceAliases) { + if (datasourceAliases || aliasesInfo.targetDeviceAlias) { const entityAliases: EntityAliases = {}; const datasourceAliasesMap: {[aliasId: string]: number} = {}; - const targetDeviceAliasesMap: {[aliasId: string]: number} = {}; + let targetDeviceAliasId: string; let aliasId: string; let datasourceIndex: number; if (datasourceAliases) { @@ -244,13 +243,10 @@ export class ImportExportService { entityAliases[aliasId] = {id: aliasId, ...datasourceAliases[datasourceIndex]}; } } - if (targetDeviceAliases) { - for (const strIndex of Object.keys(targetDeviceAliases)) { - datasourceIndex = Number(strIndex); - aliasId = this.utils.guid(); - targetDeviceAliasesMap[aliasId] = datasourceIndex; - entityAliases[aliasId] = {id: aliasId, ...targetDeviceAliases[datasourceIndex]}; - } + if (aliasesInfo.targetDeviceAlias) { + aliasId = this.utils.guid(); + targetDeviceAliasId = aliasId; + entityAliases[aliasId] = {id: aliasId, ...aliasesInfo.targetDeviceAlias}; } const aliasIds = Object.keys(entityAliases); if (aliasIds.length > 0) { @@ -267,9 +263,8 @@ export class ImportExportService { if (isDefined(datasourceAliasesMap[id])) { index = datasourceAliasesMap[id]; datasourceAliases[index] = entityAlias; - } else if (isDefined(targetDeviceAliasesMap[id])) { - index = targetDeviceAliasesMap[id]; - targetDeviceAliases[index] = entityAlias; + } else if (targetDeviceAliasId === id) { + aliasesInfo.targetDeviceAlias = entityAlias; } } return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, @@ -965,19 +960,15 @@ export class ImportExportService { private prepareAliasesInfo(aliasesInfo: AliasesInfo): AliasesInfo { const datasourceAliases = aliasesInfo.datasourceAliases; - const targetDeviceAliases = aliasesInfo.targetDeviceAliases; - if (datasourceAliases || targetDeviceAliases) { + if (datasourceAliases || aliasesInfo.targetDeviceAlias) { if (datasourceAliases) { for (const strIndex of Object.keys(datasourceAliases)) { const datasourceIndex = Number(strIndex); datasourceAliases[datasourceIndex] = this.prepareEntityAlias(datasourceAliases[datasourceIndex]); } } - if (targetDeviceAliases) { - for (const strIndex of Object.keys(targetDeviceAliases)) { - const datasourceIndex = Number(strIndex); - targetDeviceAliases[datasourceIndex] = this.prepareEntityAlias(targetDeviceAliases[datasourceIndex]); - } + if (aliasesInfo.targetDeviceAlias) { + aliasesInfo.targetDeviceAlias = this.prepareEntityAlias(aliasesInfo.targetDeviceAlias); } } return aliasesInfo; diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts index 06e6a88a2c..084646d290 100644 --- a/ui-ngx/src/app/shared/models/alias.models.ts +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -194,7 +194,7 @@ export interface EntityAliasInfo { export interface AliasesInfo { datasourceAliases: {[datasourceIndex: number]: EntityAliasInfo}; - targetDeviceAliases: {[targetDeviceAliasIndex: number]: EntityAliasInfo}; + targetDeviceAlias: EntityAliasInfo; } export interface EntityAlias extends EntityAliasInfo { diff --git a/ui-ngx/src/app/shared/models/rpc-widget-settings.models.ts b/ui-ngx/src/app/shared/models/rpc-widget-settings.models.ts new file mode 100644 index 0000000000..2e5456009a --- /dev/null +++ b/ui-ngx/src/app/shared/models/rpc-widget-settings.models.ts @@ -0,0 +1,121 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { BackgroundSettings } from '@shared/models/widget-settings.models'; + +export enum RpcInitialStateAction { + DO_NOTHING = 'DO_NOTHING', + EXECUTE_RPC = 'EXECUTE_RPC', + GET_ATTRIBUTE = 'GET_ATTRIBUTE', + GET_TIME_SERIES = 'GET_TIME_SERIES' +} + +export const rpcInitialStateActions = Object.keys(RpcInitialStateAction) as RpcInitialStateAction[]; + +export const rpcInitialStateTranslations = new Map( + [ + [RpcInitialStateAction.DO_NOTHING, 'widgets.rpc-state.do-nothing'], + [RpcInitialStateAction.EXECUTE_RPC, 'widgets.rpc-state.execute-rpc'], + [RpcInitialStateAction.GET_ATTRIBUTE, 'widgets.rpc-state.get-attribute'], + [RpcInitialStateAction.GET_TIME_SERIES, 'widgets.rpc-state.get-time-series'] + ] +); + +export interface RpcSettings { + method: string; + requestTimeout: number; + requestPersistent: boolean; + persistentPollingInterval: number; +} + +export interface RpcTelemetrySettings { + key: string; +} + +export interface RpcGetAttributeSettings extends RpcTelemetrySettings { + scope: AttributeScope | null; +} + +export interface RpcSetAttributeSettings extends RpcTelemetrySettings { + scope: AttributeScope.SERVER_SCOPE | AttributeScope.SHARED_SCOPE; +} + +export enum RpcDataToStateType { + NONE = 'NONE', + FUNCTION = 'FUNCTION' +} + +export interface RpcDataToStateSettings { + type: RpcDataToStateType; + dataToStateFunction: string; + compareToValue?: any; +} + +export interface RpcInitialStateSettings { + action: RpcInitialStateAction; + defaultValue: V; + executeRpc: RpcSettings; + getAttribute: RpcGetAttributeSettings; + getTimeSeries: RpcTelemetrySettings; + dataToState: RpcDataToStateSettings; +} + +export enum RpcUpdateStateAction { + EXECUTE_RPC = 'EXECUTE_RPC', + SET_ATTRIBUTE = 'SET_ATTRIBUTE', + ADD_TIME_SERIES = 'ADD_TIME_SERIES' +} + +export const rpcUpdateStateActions = Object.keys(RpcUpdateStateAction) as RpcUpdateStateAction[]; + +export const rpcUpdateStateTranslations = new Map( + [ + [RpcUpdateStateAction.EXECUTE_RPC, 'widgets.rpc-state.execute-rpc'], + [RpcUpdateStateAction.SET_ATTRIBUTE, 'widgets.rpc-state.set-attribute'], + [RpcUpdateStateAction.ADD_TIME_SERIES, 'widgets.rpc-state.add-time-series'] + ] +); + +export enum RpcStateToParamsType { + CONSTANT = 'CONSTANT', + FUNCTION = 'FUNCTION', + NONE = 'NONE' +} + +export interface RpcStateToParamsSettings { + type: RpcStateToParamsType; + constantValue: any; + stateToParamsFunction: string; +} + +export interface RpcUpdateStateSettings { + action: RpcUpdateStateAction; + executeRpc: RpcSettings; + setAttribute: RpcSetAttributeSettings; + putTimeSeries: RpcTelemetrySettings; + stateToParams: RpcStateToParamsSettings; +} + +export interface RpcStateBehaviourSettings { + initialState: RpcInitialStateSettings; + updateStateByValue: (value: V) => RpcUpdateStateSettings; +} + +export interface RpcStateWidgetSettings { + initialState: RpcInitialStateSettings; + background: BackgroundSettings; +} diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index 95c8cd4925..e1c6af620b 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -88,6 +88,16 @@ export const telemetryTypeTranslations = new Map( ] ); +export const telemetryTypeTranslationsShort = new Map( + [ + [LatestTelemetry.LATEST_TELEMETRY, 'attribute.scope-telemetry-short'], + [AttributeScope.CLIENT_SCOPE, 'attribute.scope-client-short'], + [AttributeScope.SERVER_SCOPE, 'attribute.scope-server-short'], + [AttributeScope.SHARED_SCOPE, 'attribute.scope-shared-short'] + ] +); + + export const isClientSideTelemetryType = new Map( [ [LatestTelemetry.LATEST_TELEMETRY, true], diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index c8e5699d82..8d7452da89 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -183,6 +183,7 @@ export interface WidgetTypeParameters { hideDataSettings?: boolean; defaultDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; defaultLatestDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; + displayRpcMessageToast?: boolean; } export interface WidgetControllerDescriptor { @@ -409,6 +410,22 @@ export interface Datasource { [key: string]: any; } +export enum TargetDeviceType { + device = 'device', + entity = 'entity' +} + +export interface TargetDevice { + type?: TargetDeviceType; + deviceId?: string; + entityAliasId?: string; +} + +export const targetDeviceValid = (targetDevice?: TargetDevice): boolean => + !!targetDevice && !!targetDevice.type && + ((targetDevice.type === TargetDeviceType.device && !!targetDevice.deviceId) || + (targetDevice.type === TargetDeviceType.entity && !!targetDevice.entityAliasId)); + export const datasourcesHasAggregation = (datasources?: Array): boolean => { if (datasources) { const foundDatasource = datasources.find(datasource => { @@ -697,7 +714,7 @@ export interface WidgetConfig { alarmSource?: Datasource; alarmFilterConfig?: AlarmFilterConfig; datasources?: Array; - targetDeviceAliasIds?: Array; + targetDevice?: TargetDevice; [key: string]: any; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 9140f0071a..0667ec04fe 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -740,6 +740,11 @@ "scope-client": "Client attributes", "scope-server": "Server attributes", "scope-shared": "Shared attributes", + "scope-client-short": "Client", + "scope-server-short": "Server", + "scope-shared-short": "Shared", + "scope-latest-short": "Latest", + "scope-any": "Any", "add": "Add attribute", "key": "Key", "key-max-length": "Key should be less than 256", @@ -5986,6 +5991,55 @@ "min-value": "Minimum value", "max-value": "Maximum value" }, + "rpc-state": { + "initial-state": "Initial state", + "initial-state-hint": "Action to get the initial value of the component.", + "turn-on": "Turn 'On'", + "turn-on-hint": "Action performed to turn ON the component.", + "turn-off": "Turn 'Off'", + "turn-off-hint": "Action performed to turn OFF the component.", + "on": "On", + "off": "Off", + "do-nothing": "Do nothing", + "execute-rpc": "Execute RPC", + "get-attribute": "Get attribute", + "set-attribute": "Set attribute", + "get-time-series": "Get time-series", + "add-time-series": "Add time-series", + "execute-rpc-text": "Execute RPC method '{{methodName}}'", + "get-attribute-text": "Use attribute '{{key}}'", + "get-time-series-text": "Use time-series '{{key}}'", + "set-attribute-to-value-text": "Set '{{key}}' attribute to: {{value}}", + "add-time-series-value-text": "Add '{{key}}' time-series value: {{value}}", + "set-attribute-text": "Set '{{key}}' attribute", + "add-time-series-text": "Add '{{key}}' time-series", + "action": "Action", + "value": "Value", + "init-value-hint": "Value that will be set until device sends data.", + "method": "Method", + "method-name-required": "Method name is required.", + "request-timeout-ms": "RPC request timeout (ms)", + "request-timeout-required": "Request timeout is required.", + "min-request-timeout-error": "Request timeout value should be greater or equal 5000 ms (5 seconds).", + "request-persistent": "RPC request persistent", + "persistent-polling-interval": "Persistent polling interval (ms)", + "persistent-polling-interval-hint": "Polling interval (ms) to get persistent RPC command response", + "persistent-polling-interval-required": "Persistent polling interval is required.", + "min-persistent-polling-interval-error": "Persistent polling interval value should be greater or equal 1000 ms (1 second).", + "attribute-scope": "Attribute scope", + "attribute-key": "Attribute key", + "attribute-key-required": "Attribute key is required.", + "time-series-key": "Time-series key", + "time-series-key-required": "Time-series key is required.", + "action-result-converter": "Action result converter", + "converter-none": "None", + "converter-function": "Function", + "converter-constant": "Constant", + "parse-value-function": "Parse value function", + "on-when-result-is": "'On' when result is", + "parameters": "Parameters", + "convert-value-function": "Convert value function" + }, "maps": { "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position", @@ -6219,6 +6273,24 @@ "label-position-left": "Left", "label-position-top": "Top" }, + "single-switch": { + "behavior": "Behavior", + "layout": "Layout", + "layout-right": "Right", + "layout-left": "Left", + "layout-centered": "Centered", + "auto-scale": "Auto scale", + "label": "Label", + "icon": "Icon", + "switch-color": "Switch color", + "on": "On", + "off": "Off", + "disabled": "Disabled", + "tumbler-color": "Tumbler color", + "on-label": "On label", + "off-label": "Off label", + "switch": "Switch" + }, "value-card": { "layout": "Layout", "layout-square": "Square", diff --git a/ui-ngx/src/assets/widget/single-switch/centered-layout.svg b/ui-ngx/src/assets/widget/single-switch/centered-layout.svg new file mode 100644 index 0000000000..1506dd8eea --- /dev/null +++ b/ui-ngx/src/assets/widget/single-switch/centered-layout.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/widget/single-switch/left-layout.svg b/ui-ngx/src/assets/widget/single-switch/left-layout.svg new file mode 100644 index 0000000000..a788fc7956 --- /dev/null +++ b/ui-ngx/src/assets/widget/single-switch/left-layout.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/widget/single-switch/right-layout.svg b/ui-ngx/src/assets/widget/single-switch/right-layout.svg new file mode 100644 index 0000000000..45a629782b --- /dev/null +++ b/ui-ngx/src/assets/widget/single-switch/right-layout.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index ee6d4167cd..67f8c6b641 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -18,6 +18,9 @@ @mixin form-row-column() { flex-direction: column; align-items: stretch; + &.align-start { + align-items: stretch; + } gap: 12px; padding: 12px 7px 12px 16px; } @@ -76,6 +79,9 @@ } > .mat-expansion-panel { padding: 16px; + &.no-padding { + padding: 0; + } .mat-expansion-panel-header { .mat-slide { margin: 0; @@ -101,8 +107,9 @@ background: none; } .mat-expansion-indicator { - height: 32px; + height: 24px; padding: 2px; + line-height: 14px; } } > .mat-expansion-panel-header-description { @@ -113,9 +120,14 @@ display: flex; flex-direction: column; gap: 16px; - padding: 16px 0 0 !important; + padding: 0 !important; + > :first-child { + margin-top: 16px; + @media #{$mat-xs} { + margin-top: 12px; + } + } @media #{$mat-xs} { - padding: 12px 0 0 !important; gap: 8px; } } @@ -166,10 +178,16 @@ @include form-row-column-breakpoint($mat-lt-md) } } + &.align-start { + align-items: flex-start; + } &.no-border { border: none; border-radius: 0; } + &.no-gap { + gap: 0; + } &.no-padding { padding: 0; } @@ -200,6 +218,10 @@ min-width: fit-content; } } + .fixed-title-height { + min-height: 40px; + line-height: 40px; + } .mat-slide:only-child { margin: 8px 0; } @@ -340,6 +362,15 @@ } } } + &.tb-suffix-absolute { + .mat-mdc-text-field-wrapper { + .mat-mdc-form-field-icon-suffix { + > .mat-icon { + right: 16px; + } + } + } + } } &.tb-chips { &.flex { @@ -385,6 +416,27 @@ } } } + &.tb-suffix-absolute { + .mat-mdc-text-field-wrapper { + .mat-mdc-form-field-icon-suffix { + padding: 0; + > .mat-icon { + position: absolute; + right: 0; + } + } + } + &.mat-mdc-form-field-has-icon-suffix { + .mat-mdc-text-field-wrapper { + padding-right: 12px; + } + &.number { + .mat-mdc-text-field-wrapper { + padding-right: 4px; + } + } + } + } } .tb-form-table { @@ -554,4 +606,29 @@ color: rgba(0, 0, 0, 0.12); } } + + .mat-mdc-chip-listbox.center-stretch { + .mat-mdc-standard-chip { + flex: 1; + &.mdc-evolution-chip--with-primary-graphic { + .mdc-evolution-chip__action--primary { + width: 100%; + padding-right: 0; + gap: 0; + .mdc-evolution-chip__graphic { + padding: 0; + flex: none; + } + } + &.mdc-evolution-chip--selected { + .mdc-evolution-chip__action--primary { + gap: 4px; + } + .mdc-evolution-chip__graphic { + width: 20px; + } + } + } + } + } } diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 8ce41f10db..0750de263f 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -364,7 +364,8 @@ mat-icon { .mat-mdc-tooltip.tb-error-tooltip { .mdc-tooltip__surface { - background-color: rgba(209, 39, 48, 0.12); + background-color: #fff2f3; + box-shadow: -2px 2px 4px 0px rgba(0,0,0,0.2); color: rgba(209, 39, 48, 1); } }