mirror of
https://github.com/apache/superset.git
synced 2026-05-17 05:45:11 +00:00
Compare commits
19 Commits
chat-proto
...
chore/fc-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
531878969e | ||
|
|
1e2d0b5f5b | ||
|
|
59b5f69627 | ||
|
|
c2980c7c42 | ||
|
|
982881ac1c | ||
|
|
2e7a2b1f2d | ||
|
|
17def46dba | ||
|
|
41e09149b6 | ||
|
|
08a9b73bd2 | ||
|
|
4ca644394f | ||
|
|
ec14530574 | ||
|
|
de802edf2a | ||
|
|
7dd8bc686b | ||
|
|
d816529f9b | ||
|
|
b9815e06c4 | ||
|
|
aee7c285ea | ||
|
|
4c14ca5dbe | ||
|
|
4db31aea1f | ||
|
|
2a133d2d89 |
@@ -71,7 +71,7 @@
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.3.7",
|
||||
"antd": "^6.4.2",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"caniuse-lite": "^1.0.30001792",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
|
||||
182
docs/yarn.lock
182
docs/yarn.lock
@@ -212,7 +212,7 @@
|
||||
resolved "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz"
|
||||
integrity sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==
|
||||
|
||||
"@ant-design/icons@^6.1.1", "@ant-design/icons@^6.2.3":
|
||||
"@ant-design/icons@^6.2.3":
|
||||
version "6.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.3.tgz#66e1c7fdea009b9c3fab6964062bedc76f308ad8"
|
||||
integrity sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==
|
||||
@@ -1158,10 +1158,10 @@
|
||||
dependencies:
|
||||
core-js-pure "^3.43.0"
|
||||
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.25.6", "@babel/runtime@^7.25.9", "@babel/runtime@^7.28.4":
|
||||
version "7.28.4"
|
||||
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz"
|
||||
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.25.6", "@babel/runtime@^7.25.9", "@babel/runtime@^7.28.4", "@babel/runtime@^7.29.2":
|
||||
version "7.29.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e"
|
||||
integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==
|
||||
|
||||
"@babel/template@^7.27.1", "@babel/template@^7.27.2", "@babel/template@^7.28.6":
|
||||
version "7.28.6"
|
||||
@@ -2924,13 +2924,13 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.24.4"
|
||||
|
||||
"@rc-component/cascader@~1.14.0":
|
||||
version "1.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.14.0.tgz#74e1fca58cb14f8f75f6e4bf1debd90534aaea7c"
|
||||
integrity sha512-Ip9356xwZUR2nbW5PRVGif4B/bDve4pLa/N+PGbvBaTnjbvmN4PFMBGQSmlDlzKP1ovxaYMvwF/dI9lXNLT4iQ==
|
||||
"@rc-component/cascader@~1.15.0":
|
||||
version "1.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.15.0.tgz#554cba8e01e94a1288547cec96422b2cfc73ff40"
|
||||
integrity sha512-ZzpMtwFCRo3fbXHuDnncARJMZQjdqA2w7aDuPofNQt+aDx39st1hgfIpEwTBLhe2Hqsvs/zOr8RTtgxTkCPySw==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.6.0"
|
||||
"@rc-component/tree" "~1.2.0"
|
||||
"@rc-component/tree" "~1.3.0"
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
@@ -2968,10 +2968,10 @@
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.3.0"
|
||||
|
||||
"@rc-component/dialog@~1.8.4":
|
||||
version "1.8.4"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/dialog/-/dialog-1.8.4.tgz#e1f05f311539852f40a5717bc3874ce0af64c6ff"
|
||||
integrity sha512-Ay6PM7phkTkquplG8fWfUGFZ2GTLx9diTl4f0d8Eqxd7W1u1KjE9AQooFQHOHnhZf0Ya3z51+5EKCWHmt/dNEw==
|
||||
"@rc-component/dialog@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/dialog/-/dialog-1.9.0.tgz#3134f8fa8644d9bc228c862668b90de048c7ea1a"
|
||||
integrity sha512-zbAAogkg4kkKum79sLE6M+vq1jSAW25zdkafrahgcTP9t9S//SD634Znd1A4c8F2Gc12ZKnehGLsVaaOvZzD2A==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.1.3"
|
||||
"@rc-component/portal" "^2.1.0"
|
||||
@@ -3025,30 +3025,30 @@
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/input@~1.1.0", "@rc-component/input@~1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmjs.org/@rc-component/input/-/input-1.1.2.tgz"
|
||||
integrity sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==
|
||||
"@rc-component/input@~1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/input/-/input-1.3.0.tgz#a8c113000bbc39089cf75337bec68120115b9e05"
|
||||
integrity sha512-IUUNOdAuWuEvDEFFgfmwQl818tiDbvXwLgon4HL1q2hJeYkqrRrYwYhJN0zfPHGTDxs3gvyVC/C02D4hWFoIcA==
|
||||
dependencies:
|
||||
"@rc-component/resize-observer" "^1.1.1"
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.6.0":
|
||||
version "1.6.0"
|
||||
resolved "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.6.0.tgz"
|
||||
integrity sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==
|
||||
"@rc-component/mentions@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.9.0.tgz#1e133d607835854430e264b681b7b32c4b49daa7"
|
||||
integrity sha512-WUwfFKDSOF5S9UPsNsXcLYtzjTxBGsftTXWRbZuxX6BYrsySISTnujfJNgaaQ6qVzaCDJ35QUkZKvsYxip1C5g==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.1.0"
|
||||
"@rc-component/menu" "~1.2.0"
|
||||
"@rc-component/textarea" "~1.1.0"
|
||||
"@rc-component/input" "~1.3.0"
|
||||
"@rc-component/menu" "~1.3.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/menu@~1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.npmjs.org/@rc-component/menu/-/menu-1.2.0.tgz"
|
||||
integrity sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==
|
||||
"@rc-component/menu@~1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/menu/-/menu-1.3.0.tgz#fc70d81ca76ae6013b0d7955f20a2393adef04b3"
|
||||
integrity sha512-u3NfiwpiEgT177qa5Yxm5QsI8i/93EBGpWj8HYZQDnh2pCZ2xtQCe/+w3pSR2NlwKOZDTCKzEhEyD09mGphssA==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.1.4"
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
@@ -3078,13 +3078,13 @@
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.2.0"
|
||||
|
||||
"@rc-component/notification@~1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.npmjs.org/@rc-component/notification/-/notification-1.2.0.tgz"
|
||||
integrity sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==
|
||||
"@rc-component/notification@~2.0.6":
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/notification/-/notification-2.0.7.tgz#f2450a482f87e4698285833c4a8efcac169acabb"
|
||||
integrity sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.1.4"
|
||||
"@rc-component/util" "^1.2.1"
|
||||
"@rc-component/util" "^1.11.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/overflow@^1.0.0":
|
||||
@@ -3105,10 +3105,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/picker@~1.9.1":
|
||||
version "1.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/picker/-/picker-1.9.1.tgz#7ffcb1e4d4655fe2f3d712773e1d3ab9cd5c2a5c"
|
||||
integrity sha512-9FBYYsvH3HMLICaPDA/1Th5FLaDkFa7qAtangIdlhKb3ZALaR745e9PsOhheJb6asS4QXc12ffiAcjdkZ4C5/g==
|
||||
"@rc-component/picker@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/picker/-/picker-1.10.0.tgz#6989f0ae67fca8db00e31f81a8217c8bc370cd34"
|
||||
integrity sha512-vVOXP2RVWozwpERGUFAehVH1Jz6o/uRrAb9qSZm1LC+iJs8rvEwFo1bzz2jlOYV+uWwu0dIuG86tnDui14Ea0w==
|
||||
dependencies:
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
@@ -3199,10 +3199,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/table@~1.9.1":
|
||||
version "1.9.1"
|
||||
resolved "https://registry.npmjs.org/@rc-component/table/-/table-1.9.1.tgz"
|
||||
integrity sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg==
|
||||
"@rc-component/table@~1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.10.0.tgz#7a98d68176f23f50a762df464f4c9142e7db3942"
|
||||
integrity sha512-SjtpcCf+rL7dDc62GKT3rXTdERjVuJvRiqjpU7g0Jc/ewCifXynHc7Nm3Em1XsD+WhGrgQtxNDScI/0+Lpfr0w==
|
||||
dependencies:
|
||||
"@rc-component/context" "^2.0.1"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
@@ -3210,28 +3210,18 @@
|
||||
"@rc-component/virtual-list" "^1.0.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tabs@~1.7.0":
|
||||
version "1.7.0"
|
||||
resolved "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.7.0.tgz"
|
||||
integrity sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==
|
||||
"@rc-component/tabs@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.9.0.tgz#8f3e3755450e5a90d240d1ed3dc140d520b1fbef"
|
||||
integrity sha512-tn1slmbbaTyt8mgwyWJcT8jo/qNiYUs6u1H7OgGQt9faYO06BJIkU5cTmMqORzIrNmSEeeUY6pD5i+JlqSHYhg==
|
||||
dependencies:
|
||||
"@rc-component/dropdown" "~1.0.0"
|
||||
"@rc-component/menu" "~1.2.0"
|
||||
"@rc-component/menu" "~1.3.0"
|
||||
"@rc-component/motion" "^1.1.3"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/textarea@~1.1.0", "@rc-component/textarea@~1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmjs.org/@rc-component/textarea/-/textarea-1.1.2.tgz"
|
||||
integrity sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.1.0"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tooltip@~1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz"
|
||||
@@ -3241,30 +3231,30 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tour@~2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/@rc-component/tour/-/tour-2.3.0.tgz"
|
||||
integrity sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow==
|
||||
"@rc-component/tour@~2.4.0":
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tour/-/tour-2.4.0.tgz#caf89cf8f2f9fb68f1fb0e0c867610015d01f432"
|
||||
integrity sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==
|
||||
dependencies:
|
||||
"@rc-component/portal" "^2.2.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
"@rc-component/util" "^1.7.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree-select@~1.8.0":
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.8.0.tgz#480e84221befbd1fa93ab2034423e2b064e41981"
|
||||
integrity sha512-iYsPq3nuLYvGqdvFAW+l+I9ASRIOVbMXyA8FGZg2lGym/GwkaWeJGzI4eJ7c9IOEhRj0oyfIN4S92Fl3J05mjQ==
|
||||
"@rc-component/tree-select@~1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.9.0.tgz#13ea516478b6cb558e04181abb0a01ae6fbdd31f"
|
||||
integrity sha512-GXcFe15a+trUl1/J3OHWQhsVWFpwFpGFK2cqYWZ1sK22Zs3KZTvMwDpzr75PIo1s6QVioVxpE/pRwRopkeDQ6w==
|
||||
dependencies:
|
||||
"@rc-component/select" "~1.6.0"
|
||||
"@rc-component/tree" "~1.2.0"
|
||||
"@rc-component/tree" "~1.3.0"
|
||||
"@rc-component/util" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tree@~1.2.0", "@rc-component/tree@~1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.2.4.tgz#cb4f7d818118b3447763e74d3a82fba6454c7317"
|
||||
integrity sha512-5Gli43+m4R7NhpYYz3Z61I6LOw9yI6CNChxgVtvrO6xB1qML7iE6QMLVMB3+FTjo2yF6uFdAHtqWPECz/zbX5w==
|
||||
"@rc-component/tree@~1.3.0", "@rc-component/tree@~1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.3.1.tgz#6983ca6bd9d5f6d04dd7258d00cb0fe71cdfe661"
|
||||
integrity sha512-zlL0PW0bTFlveTtLcA01VD/yMWKK73EywItFMgIZUY5sb6tMOAw7zV6qGzqldufqrV93ZWQB4H3NBNoTMCueJA==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.0.0"
|
||||
"@rc-component/util" "^1.8.1"
|
||||
@@ -3290,10 +3280,10 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/util@^1.1.0", "@rc-component/util@^1.10.1", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.6.2", "@rc-component/util@^1.7.0", "@rc-component/util@^1.8.1", "@rc-component/util@^1.9.0":
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.10.1.tgz#213c84c77e8b2001095530d3b0dc47c49c34ffe3"
|
||||
integrity sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng==
|
||||
"@rc-component/util@^1.1.0", "@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.6.2", "@rc-component/util@^1.7.0", "@rc-component/util@^1.8.1", "@rc-component/util@^1.9.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.11.0.tgz#965c8b44a3f57fc96dc14e5072afbe32e422fd4d"
|
||||
integrity sha512-jHG3/BYgUWiP5c7RZHiaUNToyw1L3nlPSKG2RPu+YoiD9b3ajiJwBWhsjO+ZELmCsKFAjNR5DelbKdlF0e2BDA==
|
||||
dependencies:
|
||||
is-mobile "^5.0.0"
|
||||
react-is "^18.2.0"
|
||||
@@ -5501,36 +5491,36 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.3.7:
|
||||
version "6.3.7"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.3.7.tgz#620354ec04135356cbc5ce0a666871ddc73e4117"
|
||||
integrity sha512-WTHi4bHVNKpYXLHESzU0Tts7rRNQeL84Bph9dfI3Qw7mHbTulExDcYKNHny5CTXcrBBOpraXbU9miBAwUR5vaw==
|
||||
antd@^6.4.2:
|
||||
version "6.4.2"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.4.2.tgz#9fc0fee455a5c56e7ec27855495eefadc8df636a"
|
||||
integrity sha512-PNJz8Vxc/mC3EsOg/h3e2YuaZduJ1RDp4RmySDuDmKPCxVgyp4Da4kB36o87p9hbLbOWdAWCKQlnyopsN8utKQ==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
"@ant-design/cssinjs-utils" "^2.1.2"
|
||||
"@ant-design/fast-color" "^3.0.1"
|
||||
"@ant-design/icons" "^6.1.1"
|
||||
"@ant-design/icons" "^6.2.3"
|
||||
"@ant-design/react-slick" "~2.0.0"
|
||||
"@babel/runtime" "^7.28.4"
|
||||
"@rc-component/cascader" "~1.14.0"
|
||||
"@babel/runtime" "^7.29.2"
|
||||
"@rc-component/cascader" "~1.15.0"
|
||||
"@rc-component/checkbox" "~2.0.0"
|
||||
"@rc-component/collapse" "~1.2.0"
|
||||
"@rc-component/color-picker" "~3.1.1"
|
||||
"@rc-component/dialog" "~1.8.4"
|
||||
"@rc-component/dialog" "~1.9.0"
|
||||
"@rc-component/drawer" "~1.4.2"
|
||||
"@rc-component/dropdown" "~1.0.2"
|
||||
"@rc-component/form" "~1.8.1"
|
||||
"@rc-component/image" "~1.9.0"
|
||||
"@rc-component/input" "~1.1.2"
|
||||
"@rc-component/input" "~1.3.0"
|
||||
"@rc-component/input-number" "~1.6.2"
|
||||
"@rc-component/mentions" "~1.6.0"
|
||||
"@rc-component/menu" "~1.2.0"
|
||||
"@rc-component/mentions" "~1.9.0"
|
||||
"@rc-component/menu" "~1.3.0"
|
||||
"@rc-component/motion" "^1.3.2"
|
||||
"@rc-component/mutate-observer" "^2.0.1"
|
||||
"@rc-component/notification" "~1.2.0"
|
||||
"@rc-component/notification" "~2.0.6"
|
||||
"@rc-component/pagination" "~1.2.0"
|
||||
"@rc-component/picker" "~1.9.1"
|
||||
"@rc-component/picker" "~1.10.0"
|
||||
"@rc-component/progress" "~1.0.2"
|
||||
"@rc-component/qrcode" "~1.1.1"
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
@@ -5540,13 +5530,12 @@ antd@^6.3.7:
|
||||
"@rc-component/slider" "~1.0.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
"@rc-component/table" "~1.9.1"
|
||||
"@rc-component/tabs" "~1.7.0"
|
||||
"@rc-component/textarea" "~1.1.2"
|
||||
"@rc-component/table" "~1.10.0"
|
||||
"@rc-component/tabs" "~1.9.0"
|
||||
"@rc-component/tooltip" "~1.4.0"
|
||||
"@rc-component/tour" "~2.3.0"
|
||||
"@rc-component/tree" "~1.2.4"
|
||||
"@rc-component/tree-select" "~1.8.0"
|
||||
"@rc-component/tour" "~2.4.0"
|
||||
"@rc-component/tree" "~1.3.1"
|
||||
"@rc-component/tree-select" "~1.9.0"
|
||||
"@rc-component/trigger" "^3.9.0"
|
||||
"@rc-component/upload" "~1.1.0"
|
||||
"@rc-component/util" "^1.10.1"
|
||||
@@ -7101,12 +7090,7 @@ data-view-byte-offset@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
is-data-view "^1.0.1"
|
||||
|
||||
dayjs@^1.11.11:
|
||||
version "1.11.13"
|
||||
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz"
|
||||
integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
|
||||
|
||||
dayjs@^1.11.19:
|
||||
dayjs@^1.11.11, dayjs@^1.11.19:
|
||||
version "1.11.20"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938"
|
||||
integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==
|
||||
|
||||
233
superset-frontend/package-lock.json
generated
233
superset-frontend/package-lock.json
generated
@@ -286,7 +286,7 @@
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.22.0",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
@@ -294,7 +294,7 @@
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.3",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.4.1",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
@@ -3630,9 +3630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3664,9 +3664,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3698,9 +3698,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -46492,14 +46492,13 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"version": "4.22.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
|
||||
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
@@ -46512,9 +46511,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -46529,9 +46528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -46546,9 +46545,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -46563,9 +46562,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -46580,9 +46579,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -46597,9 +46596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -46614,9 +46613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -46631,9 +46630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -46648,9 +46647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -46665,9 +46664,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -46682,9 +46681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -46699,9 +46698,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -46716,9 +46715,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -46733,9 +46732,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -46750,9 +46749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -46767,9 +46766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -46784,9 +46783,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -46801,9 +46800,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -46818,9 +46817,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -46835,9 +46834,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -46852,9 +46851,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -46869,9 +46868,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -46886,9 +46885,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -46903,9 +46902,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -46916,32 +46915,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
@@ -48645,9 +48644,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz",
|
||||
"integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==",
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz",
|
||||
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -367,7 +367,7 @@
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.22.0",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
@@ -375,7 +375,7 @@
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.3",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.4.1",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent, ReactNode } from 'react';
|
||||
import { ReactNode, useCallback, useContext, useEffect, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { JsonObject } from '@superset-ui/core';
|
||||
|
||||
@@ -90,165 +90,61 @@ interface VisibilityEventData {
|
||||
ts: number;
|
||||
}
|
||||
|
||||
class Dashboard extends PureComponent<DashboardProps> {
|
||||
static contextType = PluginContext;
|
||||
function unload(event: BeforeUnloadEvent): string {
|
||||
const message = t('You have unsaved changes.');
|
||||
// Set returnValue on the actual event object to trigger the browser prompt
|
||||
event.returnValue = message;
|
||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||
}
|
||||
|
||||
// Use type assertion when accessing context instead of declare field
|
||||
// to avoid babel transformation issues in Jest
|
||||
|
||||
static defaultProps = {
|
||||
timeout: 60,
|
||||
userId: '',
|
||||
};
|
||||
|
||||
appliedFilters: ActiveFilters;
|
||||
|
||||
appliedOwnDataCharts: JsonObject;
|
||||
|
||||
visibilityEventData: VisibilityEventData;
|
||||
|
||||
static onBeforeUnload(hasChanged: boolean): void {
|
||||
if (hasChanged) {
|
||||
window.addEventListener('beforeunload', Dashboard.unload);
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', Dashboard.unload);
|
||||
}
|
||||
function onBeforeUnload(hasChanged: boolean): void {
|
||||
if (hasChanged) {
|
||||
window.addEventListener('beforeunload', unload);
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', unload);
|
||||
}
|
||||
}
|
||||
|
||||
static unload(): string {
|
||||
const message = t('You have unsaved changes.');
|
||||
// Gecko + IE: returnValue is typed as boolean but historically accepts string
|
||||
(window.event as BeforeUnloadEvent).returnValue = message;
|
||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||
}
|
||||
function Dashboard({
|
||||
actions,
|
||||
dashboardId,
|
||||
editMode,
|
||||
isPublished,
|
||||
hasUnsavedChanges,
|
||||
slices,
|
||||
activeFilters,
|
||||
chartConfiguration,
|
||||
datasources,
|
||||
ownDataCharts,
|
||||
layout,
|
||||
impressionId,
|
||||
timeout = 60,
|
||||
userId = '',
|
||||
children,
|
||||
}: DashboardProps): JSX.Element {
|
||||
const context = useContext(PluginContext) as PluginContextType;
|
||||
|
||||
constructor(props: DashboardProps) {
|
||||
super(props);
|
||||
this.appliedFilters = props.activeFilters ?? {};
|
||||
this.appliedOwnDataCharts = props.ownDataCharts ?? {};
|
||||
this.visibilityEventData = { start_offset: 0, ts: 0 };
|
||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
||||
}
|
||||
// Use refs to track mutable values that persist across renders
|
||||
const appliedFiltersRef = useRef<ActiveFilters>(activeFilters ?? {});
|
||||
const appliedOwnDataChartsRef = useRef<JsonObject>(ownDataCharts ?? {});
|
||||
const visibilityEventDataRef = useRef<VisibilityEventData>({
|
||||
start_offset: 0,
|
||||
ts: 0,
|
||||
});
|
||||
const prevLayoutRef = useRef<DashboardLayout>(layout);
|
||||
const prevDashboardIdRef = useRef<number>(dashboardId);
|
||||
|
||||
componentDidMount(): void {
|
||||
const bootstrapData = getBootstrapData();
|
||||
const { editMode, isPublished, layout } = this.props;
|
||||
const eventData: Record<string, unknown> = {
|
||||
is_soft_navigation: Logger.timeOriginOffset > 0,
|
||||
is_edit_mode: editMode,
|
||||
mount_duration: Logger.getTimestamp(),
|
||||
is_empty: isDashboardEmpty(layout),
|
||||
is_published: isPublished,
|
||||
bootstrap_data_length: JSON.stringify(bootstrapData).length,
|
||||
};
|
||||
const directLinkComponentId = getLocationHash();
|
||||
if (directLinkComponentId) {
|
||||
eventData.target_id = directLinkComponentId;
|
||||
}
|
||||
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
|
||||
|
||||
// Handle browser tab visibility change
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.visibilityEventData = {
|
||||
start_offset: Logger.getTimestamp(),
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
}
|
||||
window.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.applyCharts();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: DashboardProps): void {
|
||||
this.applyCharts();
|
||||
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
|
||||
const nextChartIds = getChartIdsFromLayout(this.props.layout);
|
||||
|
||||
if (prevProps.dashboardId !== this.props.dashboardId) {
|
||||
// single-page-app navigation check
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentChartIds.length < nextChartIds.length) {
|
||||
const newChartIds = nextChartIds.filter(
|
||||
key => currentChartIds.indexOf(key) === -1,
|
||||
);
|
||||
newChartIds.forEach(newChartId =>
|
||||
this.props.actions.addSliceToDashboard(
|
||||
newChartId,
|
||||
getLayoutComponentFromChartId(this.props.layout, newChartId),
|
||||
),
|
||||
);
|
||||
} else if (currentChartIds.length > nextChartIds.length) {
|
||||
// remove chart
|
||||
const removedChartIds = currentChartIds.filter(
|
||||
key => nextChartIds.indexOf(key) === -1,
|
||||
);
|
||||
removedChartIds.forEach(removedChartId =>
|
||||
this.props.actions.removeSliceFromDashboard(removedChartId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
applyCharts(): void {
|
||||
const {
|
||||
activeFilters,
|
||||
ownDataCharts,
|
||||
chartConfiguration,
|
||||
hasUnsavedChanges,
|
||||
editMode,
|
||||
} = this.props;
|
||||
const { appliedFilters, appliedOwnDataCharts } = this;
|
||||
if (!chartConfiguration) {
|
||||
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
|
||||
// for correct comparing of filters to avoid unnecessary requests
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!editMode &&
|
||||
(!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, {
|
||||
ignoreUndefined: true,
|
||||
}) ||
|
||||
!areObjectsEqual(appliedFilters, activeFilters, {
|
||||
ignoreUndefined: true,
|
||||
}))
|
||||
) {
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
Dashboard.onBeforeUnload(true);
|
||||
} else {
|
||||
Dashboard.onBeforeUnload(false);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.props.actions.clearDataMaskState();
|
||||
this.props.actions.clearAllChartStates();
|
||||
}
|
||||
|
||||
onVisibilityChange(): void {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// from visible to hidden
|
||||
this.visibilityEventData = {
|
||||
start_offset: Logger.getTimestamp(),
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
// from hidden to visible
|
||||
const logStart = this.visibilityEventData.start_offset;
|
||||
this.props.actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
|
||||
...this.visibilityEventData,
|
||||
duration: Logger.getTimestamp() - logStart,
|
||||
const refreshCharts = useCallback(
|
||||
(ids: (string | number)[]): void => {
|
||||
ids.forEach(id => {
|
||||
actions.triggerQuery(true, id);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
applyFilters(): void {
|
||||
const { appliedFilters } = this;
|
||||
const { activeFilters, ownDataCharts, slices } = this.props;
|
||||
const applyFilters = useCallback((): void => {
|
||||
const appliedFilters = appliedFiltersRef.current;
|
||||
|
||||
// refresh charts if a filter was removed, added, or changed
|
||||
|
||||
@@ -258,7 +154,7 @@ class Dashboard extends PureComponent<DashboardProps> {
|
||||
const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys));
|
||||
const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts(
|
||||
ownDataCharts,
|
||||
this.appliedOwnDataCharts,
|
||||
appliedOwnDataChartsRef.current,
|
||||
);
|
||||
|
||||
[...allKeys].forEach(filterKey => {
|
||||
@@ -321,24 +217,145 @@ class Dashboard extends PureComponent<DashboardProps> {
|
||||
});
|
||||
|
||||
// remove dup in affectedChartIds
|
||||
this.refreshCharts([...new Set(affectedChartIds)]);
|
||||
this.appliedFilters = activeFilters;
|
||||
this.appliedOwnDataCharts = ownDataCharts;
|
||||
}
|
||||
refreshCharts([...new Set(affectedChartIds)]);
|
||||
appliedFiltersRef.current = activeFilters;
|
||||
appliedOwnDataChartsRef.current = ownDataCharts;
|
||||
}, [activeFilters, ownDataCharts, slices, refreshCharts]);
|
||||
|
||||
refreshCharts(ids: (string | number)[]): void {
|
||||
ids.forEach(id => {
|
||||
this.props.actions.triggerQuery(true, id);
|
||||
});
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const context = this.context as PluginContextType;
|
||||
if (context.loading) {
|
||||
return <Loading />;
|
||||
const applyCharts = useCallback((): void => {
|
||||
if (!chartConfiguration) {
|
||||
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
|
||||
// for correct comparing of filters to avoid unnecessary requests
|
||||
return;
|
||||
}
|
||||
return this.props.children;
|
||||
|
||||
if (
|
||||
!editMode &&
|
||||
(!areObjectsEqual(appliedOwnDataChartsRef.current, ownDataCharts, {
|
||||
ignoreUndefined: true,
|
||||
}) ||
|
||||
!areObjectsEqual(appliedFiltersRef.current, activeFilters, {
|
||||
ignoreUndefined: true,
|
||||
}))
|
||||
) {
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
onBeforeUnload(true);
|
||||
} else {
|
||||
onBeforeUnload(false);
|
||||
}
|
||||
}, [
|
||||
chartConfiguration,
|
||||
editMode,
|
||||
ownDataCharts,
|
||||
activeFilters,
|
||||
hasUnsavedChanges,
|
||||
applyFilters,
|
||||
]);
|
||||
|
||||
const onVisibilityChange = useCallback((): void => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// from visible to hidden
|
||||
visibilityEventDataRef.current = {
|
||||
start_offset: Logger.getTimestamp(),
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
// from hidden to visible
|
||||
const logStart = visibilityEventDataRef.current.start_offset;
|
||||
actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
|
||||
...visibilityEventDataRef.current,
|
||||
duration: Logger.getTimestamp() - logStart,
|
||||
});
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
// componentDidMount equivalent
|
||||
useEffect(() => {
|
||||
const bootstrapData = getBootstrapData();
|
||||
const eventData: Record<string, unknown> = {
|
||||
is_soft_navigation: Logger.timeOriginOffset > 0,
|
||||
is_edit_mode: editMode,
|
||||
mount_duration: Logger.getTimestamp(),
|
||||
is_empty: isDashboardEmpty(layout),
|
||||
is_published: isPublished,
|
||||
bootstrap_data_length: JSON.stringify(bootstrapData).length,
|
||||
};
|
||||
const directLinkComponentId = getLocationHash();
|
||||
if (directLinkComponentId) {
|
||||
eventData.target_id = directLinkComponentId;
|
||||
}
|
||||
actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
|
||||
|
||||
// Handle browser tab visibility change
|
||||
if (document.visibilityState === 'hidden') {
|
||||
visibilityEventDataRef.current = {
|
||||
start_offset: Logger.getTimestamp(),
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
// componentWillUnmount equivalent
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
onBeforeUnload(false); // Remove beforeunload listener on unmount
|
||||
actions.clearDataMaskState();
|
||||
actions.clearAllChartStates();
|
||||
};
|
||||
// Only run on mount/unmount - intentionally excluding deps that would cause re-runs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Apply charts on every render (like componentDidMount + componentDidUpdate calling applyCharts)
|
||||
useEffect(() => {
|
||||
applyCharts();
|
||||
}, [applyCharts]);
|
||||
|
||||
// componentDidUpdate equivalent for layout changes
|
||||
useEffect(() => {
|
||||
const prevLayout = prevLayoutRef.current;
|
||||
const prevDashboardId = prevDashboardIdRef.current;
|
||||
|
||||
// Update refs for next comparison
|
||||
prevLayoutRef.current = layout;
|
||||
prevDashboardIdRef.current = dashboardId;
|
||||
|
||||
const currentChartIds = getChartIdsFromLayout(prevLayout);
|
||||
const nextChartIds = getChartIdsFromLayout(layout);
|
||||
|
||||
if (prevDashboardId !== dashboardId) {
|
||||
// single-page-app navigation check
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentChartIds.length < nextChartIds.length) {
|
||||
const newChartIds = nextChartIds.filter(
|
||||
key => currentChartIds.indexOf(key) === -1,
|
||||
);
|
||||
newChartIds.forEach(newChartId =>
|
||||
actions.addSliceToDashboard(
|
||||
newChartId,
|
||||
getLayoutComponentFromChartId(layout, newChartId),
|
||||
),
|
||||
);
|
||||
} else if (currentChartIds.length > nextChartIds.length) {
|
||||
// remove chart
|
||||
const removedChartIds = currentChartIds.filter(
|
||||
key => nextChartIds.indexOf(key) === -1,
|
||||
);
|
||||
removedChartIds.forEach(removedChartId =>
|
||||
actions.removeSliceFromDashboard(removedChartId),
|
||||
);
|
||||
}
|
||||
}, [layout, dashboardId, actions]);
|
||||
|
||||
if (context.loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -31,9 +31,10 @@ export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => {
|
||||
};
|
||||
|
||||
export const shouldFocusTabs = (
|
||||
event: { target: { className: string } },
|
||||
container: { contains: (arg0: any) => any },
|
||||
) =>
|
||||
event: { target: HTMLElement },
|
||||
container: Pick<Node, 'contains'> | null,
|
||||
_menuRef: HTMLDivElement | null,
|
||||
): boolean =>
|
||||
// don't focus the tabs when we click on a tab
|
||||
event.target.className === 'ant-tabs-nav-wrap' ||
|
||||
container.contains(event.target);
|
||||
(container?.contains(event.target) ?? false);
|
||||
|
||||
@@ -16,12 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent, Fragment } from 'react';
|
||||
import { withTheme } from '@emotion/react';
|
||||
import { Fragment, useCallback, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { addAlpha } from '@superset-ui/core';
|
||||
import { css, styled, type SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { EmptyState } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
@@ -48,11 +47,6 @@ export interface DashboardGridProps {
|
||||
setEditMode?: (editMode: boolean) => void;
|
||||
width: number;
|
||||
dashboardId?: number;
|
||||
theme: SupersetTheme;
|
||||
}
|
||||
|
||||
interface DashboardGridState {
|
||||
isResizing: boolean;
|
||||
}
|
||||
|
||||
interface DropProps {
|
||||
@@ -131,261 +125,235 @@ const GridColumnGuide = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
class DashboardGrid extends PureComponent<
|
||||
DashboardGridProps,
|
||||
DashboardGridState
|
||||
> {
|
||||
grid: HTMLDivElement | null;
|
||||
function DashboardGrid({
|
||||
depth,
|
||||
editMode,
|
||||
canEdit,
|
||||
gridComponent,
|
||||
handleComponentDrop,
|
||||
isComponentVisible,
|
||||
resizeComponent,
|
||||
setDirectPathToChild,
|
||||
setEditMode,
|
||||
width,
|
||||
dashboardId,
|
||||
}: DashboardGridProps) {
|
||||
const theme = useTheme();
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
constructor(props: DashboardGridProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isResizing: false,
|
||||
};
|
||||
this.grid = null;
|
||||
this.handleResizeStart = this.handleResizeStart.bind(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.handleResizeStop = this.handleResizeStop.bind(this);
|
||||
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
|
||||
this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
|
||||
this.setGridRef = this.setGridRef.bind(this);
|
||||
this.handleChangeTab = this.handleChangeTab.bind(this);
|
||||
}
|
||||
const setGridRef = useCallback((ref: HTMLDivElement | null): void => {
|
||||
gridRef.current = ref;
|
||||
}, []);
|
||||
|
||||
getRowGuidePosition(resizeRef: HTMLElement | null): number | null {
|
||||
if (resizeRef && this.grid) {
|
||||
return (
|
||||
resizeRef.getBoundingClientRect().bottom -
|
||||
this.grid.getBoundingClientRect().top -
|
||||
2
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const handleResizeStart = useCallback((): void => {
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
setGridRef(ref: HTMLDivElement | null): void {
|
||||
this.grid = ref;
|
||||
}
|
||||
const handleResize = useCallback(
|
||||
(
|
||||
_event: MouseEvent | TouchEvent,
|
||||
_direction: string,
|
||||
_elementRef: HTMLElement,
|
||||
_delta: { width: number; height: number },
|
||||
): void => {
|
||||
// no-op: resize position tracking not implemented
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
handleResizeStart(): void {
|
||||
this.setState(() => ({
|
||||
isResizing: true,
|
||||
}));
|
||||
}
|
||||
|
||||
handleResize(
|
||||
_event: MouseEvent | TouchEvent,
|
||||
_direction: string,
|
||||
_elementRef: HTMLElement,
|
||||
_delta: { width: number; height: number },
|
||||
): void {
|
||||
// no-op: resize position is tracked via getRowGuidePosition
|
||||
}
|
||||
|
||||
handleResizeStop(
|
||||
_event: MouseEvent | TouchEvent,
|
||||
_direction: string,
|
||||
_elementRef: HTMLElement,
|
||||
delta: { width: number; height: number },
|
||||
id: string,
|
||||
): void {
|
||||
this.props.resizeComponent({
|
||||
id,
|
||||
width: delta.width,
|
||||
height: delta.height,
|
||||
});
|
||||
|
||||
this.setState(() => ({
|
||||
isResizing: false,
|
||||
}));
|
||||
}
|
||||
|
||||
handleTopDropTargetDrop(dropResult: DropResult): void {
|
||||
if (dropResult?.destination) {
|
||||
this.props.handleComponentDrop({
|
||||
...dropResult,
|
||||
destination: {
|
||||
...dropResult.destination,
|
||||
// force appending as the first child if top drop target
|
||||
index: 0,
|
||||
},
|
||||
const handleResizeStop = useCallback(
|
||||
(
|
||||
_event: MouseEvent | TouchEvent,
|
||||
_direction: string,
|
||||
_elementRef: HTMLElement,
|
||||
delta: { width: number; height: number },
|
||||
id: string,
|
||||
): void => {
|
||||
resizeComponent({
|
||||
id,
|
||||
width: delta.width,
|
||||
height: delta.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleChangeTab({ pathToTabIndex }: { pathToTabIndex: string[] }): void {
|
||||
this.props.setDirectPathToChild(pathToTabIndex);
|
||||
}
|
||||
setIsResizing(false);
|
||||
},
|
||||
[resizeComponent],
|
||||
);
|
||||
|
||||
render() {
|
||||
const {
|
||||
gridComponent,
|
||||
handleComponentDrop,
|
||||
depth,
|
||||
width,
|
||||
isComponentVisible,
|
||||
editMode,
|
||||
canEdit,
|
||||
setEditMode,
|
||||
dashboardId,
|
||||
theme,
|
||||
} = this.props;
|
||||
const columnPlusGutterWidth =
|
||||
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
|
||||
const handleTopDropTargetDrop = useCallback(
|
||||
(dropResult: DropResult): void => {
|
||||
if (dropResult?.destination) {
|
||||
handleComponentDrop({
|
||||
...dropResult,
|
||||
destination: {
|
||||
...dropResult.destination,
|
||||
// force appending as the first child if top drop target
|
||||
index: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleComponentDrop],
|
||||
);
|
||||
|
||||
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
|
||||
const { isResizing } = this.state;
|
||||
const handleChangeTab = useCallback(
|
||||
({ pathToTabIndex }: { pathToTabIndex: string[] }): void => {
|
||||
setDirectPathToChild(pathToTabIndex);
|
||||
},
|
||||
[setDirectPathToChild],
|
||||
);
|
||||
|
||||
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
|
||||
const shouldDisplayTopLevelTabEmptyState =
|
||||
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
|
||||
const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
|
||||
|
||||
const dashboardEmptyState = editMode && (
|
||||
<EmptyState
|
||||
title={t('Drag and drop components and charts to the dashboard')}
|
||||
description={t(
|
||||
'You can create a new chart or use existing ones from the panel on the right',
|
||||
)}
|
||||
size="large"
|
||||
buttonText={
|
||||
<>
|
||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||
{t('Create a new chart')}
|
||||
</>
|
||||
}
|
||||
buttonAction={() => {
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
});
|
||||
}}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
|
||||
|
||||
const topLevelTabEmptyState = editMode ? (
|
||||
<EmptyState
|
||||
title={t('Drag and drop components to this tab')}
|
||||
size="large"
|
||||
description={t(
|
||||
`You can create a new chart or use existing ones from the panel on the right`,
|
||||
)}
|
||||
buttonText={
|
||||
<>
|
||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||
{t('Create a new chart')}
|
||||
</>
|
||||
}
|
||||
buttonAction={() => {
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
});
|
||||
}}
|
||||
image="chart.svg"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t('There are no components added to this tab')}
|
||||
size="large"
|
||||
description={
|
||||
canEdit && t('You can add the components in the edit mode.')
|
||||
}
|
||||
buttonText={canEdit ? t('Edit the dashboard') : undefined}
|
||||
buttonAction={
|
||||
canEdit
|
||||
? () => {
|
||||
setEditMode?.(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
|
||||
const shouldDisplayTopLevelTabEmptyState =
|
||||
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
|
||||
|
||||
return width < 100 ? null : (
|
||||
<>
|
||||
{shouldDisplayEmptyState && (
|
||||
<DashboardEmptyStateContainer>
|
||||
{shouldDisplayTopLevelTabEmptyState
|
||||
? topLevelTabEmptyState
|
||||
: dashboardEmptyState}
|
||||
</DashboardEmptyStateContainer>
|
||||
)}
|
||||
<div className="dashboard-grid" ref={this.setGridRef}>
|
||||
<GridContent
|
||||
className="grid-content"
|
||||
data-test="grid-content"
|
||||
editMode={editMode}
|
||||
>
|
||||
{/* make the area above components droppable */}
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={gridComponent}
|
||||
depth={depth}
|
||||
parentComponent={null}
|
||||
index={0}
|
||||
orientation="column"
|
||||
onDrop={this.handleTopDropTargetDrop}
|
||||
className={classNames({
|
||||
'empty-droptarget': true,
|
||||
'empty-droptarget--full':
|
||||
gridComponent?.children?.length === 0,
|
||||
})}
|
||||
editMode
|
||||
dropToChild={gridComponent?.children?.length === 0}
|
||||
>
|
||||
{renderDraggableContent}
|
||||
</Droppable>
|
||||
)}
|
||||
{gridComponent?.children?.map((id, index) => (
|
||||
<Fragment key={id}>
|
||||
<DashboardComponent
|
||||
id={id}
|
||||
parentId={gridComponent.id}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
availableColumnCount={GRID_COLUMN_COUNT}
|
||||
columnWidth={columnWidth}
|
||||
isComponentVisible={isComponentVisible}
|
||||
onResizeStart={this.handleResizeStart}
|
||||
onResize={this.handleResize}
|
||||
onResizeStop={this.handleResizeStop}
|
||||
onChangeTab={this.handleChangeTab}
|
||||
const dashboardEmptyState = editMode && (
|
||||
<EmptyState
|
||||
title={t('Drag and drop components and charts to the dashboard')}
|
||||
description={t(
|
||||
'You can create a new chart or use existing ones from the panel on the right',
|
||||
)}
|
||||
size="large"
|
||||
buttonText={
|
||||
<>
|
||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||
{t('Create a new chart')}
|
||||
</>
|
||||
}
|
||||
buttonAction={() => {
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
});
|
||||
}}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
|
||||
const topLevelTabEmptyState = editMode ? (
|
||||
<EmptyState
|
||||
title={t('Drag and drop components to this tab')}
|
||||
size="large"
|
||||
description={t(
|
||||
`You can create a new chart or use existing ones from the panel on the right`,
|
||||
)}
|
||||
buttonText={
|
||||
<>
|
||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||
{t('Create a new chart')}
|
||||
</>
|
||||
}
|
||||
buttonAction={() => {
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
});
|
||||
}}
|
||||
image="chart.svg"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t('There are no components added to this tab')}
|
||||
size="large"
|
||||
description={canEdit && t('You can add the components in the edit mode.')}
|
||||
buttonText={canEdit ? t('Edit the dashboard') : undefined}
|
||||
buttonAction={
|
||||
canEdit
|
||||
? () => {
|
||||
setEditMode?.(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
|
||||
return width < 100 ? null : (
|
||||
<>
|
||||
{shouldDisplayEmptyState && (
|
||||
<DashboardEmptyStateContainer>
|
||||
{shouldDisplayTopLevelTabEmptyState
|
||||
? topLevelTabEmptyState
|
||||
: dashboardEmptyState}
|
||||
</DashboardEmptyStateContainer>
|
||||
)}
|
||||
<div className="dashboard-grid" ref={setGridRef}>
|
||||
<GridContent
|
||||
className="grid-content"
|
||||
data-test="grid-content"
|
||||
editMode={editMode}
|
||||
>
|
||||
{/* make the area above components droppable */}
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={gridComponent}
|
||||
depth={depth}
|
||||
parentComponent={null}
|
||||
index={0}
|
||||
orientation="column"
|
||||
onDrop={handleTopDropTargetDrop}
|
||||
className={classNames({
|
||||
'empty-droptarget': true,
|
||||
'empty-droptarget--full': gridComponent?.children?.length === 0,
|
||||
})}
|
||||
editMode
|
||||
dropToChild={gridComponent?.children?.length === 0}
|
||||
>
|
||||
{renderDraggableContent}
|
||||
</Droppable>
|
||||
)}
|
||||
{gridComponent?.children?.map((id, index) => (
|
||||
<Fragment key={id}>
|
||||
<DashboardComponent
|
||||
id={id}
|
||||
parentId={gridComponent.id}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
availableColumnCount={GRID_COLUMN_COUNT}
|
||||
columnWidth={columnWidth}
|
||||
isComponentVisible={isComponentVisible}
|
||||
onResizeStart={handleResizeStart}
|
||||
onResize={handleResize}
|
||||
onResizeStop={handleResizeStop}
|
||||
onChangeTab={handleChangeTab}
|
||||
/>
|
||||
{/* make the area below components droppable */}
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={gridComponent}
|
||||
depth={depth}
|
||||
parentComponent={null}
|
||||
index={index + 1}
|
||||
orientation="column"
|
||||
onDrop={handleComponentDrop}
|
||||
className="empty-droptarget"
|
||||
editMode
|
||||
>
|
||||
{renderDraggableContent}
|
||||
</Droppable>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{isResizing &&
|
||||
Array(GRID_COLUMN_COUNT)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<GridColumnGuide
|
||||
key={`grid-column-${i}`}
|
||||
className="grid-column-guide"
|
||||
style={{
|
||||
left: i * GRID_GUTTER_SIZE + i * columnWidth,
|
||||
width: columnWidth,
|
||||
}}
|
||||
/>
|
||||
{/* make the area below components droppable */}
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={gridComponent}
|
||||
depth={depth}
|
||||
parentComponent={null}
|
||||
index={index + 1}
|
||||
orientation="column"
|
||||
onDrop={handleComponentDrop}
|
||||
className="empty-droptarget"
|
||||
editMode
|
||||
>
|
||||
{renderDraggableContent}
|
||||
</Droppable>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{isResizing &&
|
||||
Array(GRID_COLUMN_COUNT)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<GridColumnGuide
|
||||
key={`grid-column-${i}`}
|
||||
className="grid-column-guide"
|
||||
style={{
|
||||
left: i * GRID_GUTTER_SIZE + i * columnWidth,
|
||||
width: columnWidth,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</GridContent>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
))}
|
||||
</GridContent>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTheme(DashboardGrid);
|
||||
export default DashboardGrid;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Component } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Tooltip, PublishedLabel } from '@superset-ui/core/components';
|
||||
import { HeaderProps, HeaderDropdownProps } from '../Header/types';
|
||||
@@ -43,70 +43,64 @@ const publishedTooltip = t(
|
||||
'This dashboard is published. Click to make it a draft.',
|
||||
);
|
||||
|
||||
export default class PublishedStatus extends Component<DashboardPublishedStatusType> {
|
||||
constructor(props: DashboardPublishedStatusType) {
|
||||
super(props);
|
||||
this.togglePublished = this.togglePublished.bind(this);
|
||||
}
|
||||
export default function PublishedStatus({
|
||||
dashboardId,
|
||||
userCanEdit,
|
||||
userCanSave,
|
||||
isPublished,
|
||||
savePublished,
|
||||
}: DashboardPublishedStatusType) {
|
||||
const togglePublished = useCallback(() => {
|
||||
savePublished(dashboardId, !isPublished);
|
||||
}, [dashboardId, isPublished, savePublished]);
|
||||
|
||||
togglePublished() {
|
||||
this.props.savePublished(this.props.dashboardId, !this.props.isPublished);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isPublished, userCanEdit, userCanSave } = this.props;
|
||||
|
||||
// Show everybody the draft badge
|
||||
if (!isPublished) {
|
||||
// if they can edit the dash, make the badge a button
|
||||
if (userCanEdit && userCanSave) {
|
||||
return (
|
||||
<Tooltip
|
||||
id="unpublished-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={draftButtonTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel
|
||||
isPublished={isPublished}
|
||||
onClick={this.togglePublished}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// Show everybody the draft badge
|
||||
if (!isPublished) {
|
||||
// if they can edit the dash, make the badge a button
|
||||
if (userCanEdit && userCanSave) {
|
||||
return (
|
||||
<Tooltip
|
||||
id="unpublished-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={draftDivTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel isPublished={isPublished} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Show the published badge for the owner of the dashboard to toggle
|
||||
if (userCanEdit && userCanSave) {
|
||||
return (
|
||||
<Tooltip
|
||||
id="published-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={publishedTooltip}
|
||||
title={draftButtonTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel
|
||||
isPublished={isPublished}
|
||||
onClick={this.togglePublished}
|
||||
onClick={togglePublished}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show anything if one doesn't own the dashboard and it is published
|
||||
return null;
|
||||
return (
|
||||
<Tooltip
|
||||
id="unpublished-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={draftDivTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel isPublished={isPublished} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Show the published badge for the owner of the dashboard to toggle
|
||||
if (userCanEdit && userCanSave) {
|
||||
return (
|
||||
<Tooltip
|
||||
id="published-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={publishedTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel isPublished={isPublished} onClick={togglePublished} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show anything if one doesn't own the dashboard and it is published
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint-env browser */
|
||||
import { Component } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
// @ts-expect-error
|
||||
import { createFilter } from 'react-search-input';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
import { debounce, pickBy } from 'lodash';
|
||||
import { Dispatch } from 'redux';
|
||||
import { Slice } from 'src/dashboard/types';
|
||||
import { withTheme, Theme } from '@emotion/react';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import type { ConnectDragSource } from 'react-dnd';
|
||||
import AddSliceCard from './AddSliceCard';
|
||||
@@ -58,7 +57,6 @@ import { DragDroppable } from './dnd/DragDroppable';
|
||||
import { datasetLabelLower } from 'src/features/semanticLayers/label';
|
||||
|
||||
export type SliceAdderProps = {
|
||||
theme: Theme;
|
||||
fetchSlices: (
|
||||
userId?: number,
|
||||
filter_value?: string,
|
||||
@@ -77,14 +75,6 @@ export type SliceAdderProps = {
|
||||
dashboardId: number;
|
||||
};
|
||||
|
||||
type SliceAdderState = {
|
||||
filteredSlices: Slice[];
|
||||
searchTerm: string;
|
||||
sortBy: keyof Slice;
|
||||
selectedSliceIdsSet: Set<number>;
|
||||
showOnlyMyCharts: boolean;
|
||||
};
|
||||
|
||||
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
|
||||
const KEYS_TO_SORT = {
|
||||
slice_name: t('name'),
|
||||
@@ -174,295 +164,284 @@ function getFilteredSortedSlices(
|
||||
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
|
||||
.sort(sortByComparator(sortBy));
|
||||
}
|
||||
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
||||
private slicesRequest?: AbortController | Promise<void>;
|
||||
|
||||
static defaultProps = {
|
||||
selectedSliceIds: [],
|
||||
editMode: false,
|
||||
errorMessage: '',
|
||||
};
|
||||
function SliceAdder({
|
||||
fetchSlices,
|
||||
updateSlices,
|
||||
isLoading,
|
||||
slices,
|
||||
errorMessage = '',
|
||||
userId,
|
||||
selectedSliceIds = [],
|
||||
editMode = false,
|
||||
dashboardId,
|
||||
}: SliceAdderProps) {
|
||||
const theme = useTheme();
|
||||
const slicesRequestRef = useRef<AbortController | Promise<void>>();
|
||||
|
||||
constructor(props: SliceAdderProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filteredSlices: [],
|
||||
searchTerm: '',
|
||||
sortBy: DEFAULT_SORT_KEY,
|
||||
selectedSliceIdsSet: new Set(props.selectedSliceIds),
|
||||
showOnlyMyCharts: getItem(
|
||||
LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
|
||||
true,
|
||||
),
|
||||
};
|
||||
this.rowRenderer = this.rowRenderer.bind(this);
|
||||
this.searchUpdated = this.searchUpdated.bind(this);
|
||||
this.handleSelect = this.handleSelect.bind(this);
|
||||
this.userIdForFetch = this.userIdForFetch.bind(this);
|
||||
this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
|
||||
}
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<keyof Slice>(DEFAULT_SORT_KEY);
|
||||
const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState(
|
||||
() => new Set(selectedSliceIds),
|
||||
);
|
||||
|
||||
userIdForFetch() {
|
||||
return this.state.showOnlyMyCharts ? this.props.userId : undefined;
|
||||
}
|
||||
// Refs to track latest values for cleanup effect
|
||||
const latestSlicesRef = useRef(slices);
|
||||
const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
|
||||
const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
|
||||
getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
this.userIdForFetch(),
|
||||
'',
|
||||
this.state.sortBy,
|
||||
);
|
||||
}
|
||||
// Keep refs updated with latest values
|
||||
useEffect(() => {
|
||||
latestSlicesRef.current = slices;
|
||||
}, [slices]);
|
||||
|
||||
componentDidUpdate(prevProps: SliceAdderProps) {
|
||||
const nextState: SliceAdderState = {} as SliceAdderState;
|
||||
if (this.props.lastUpdated !== prevProps.lastUpdated) {
|
||||
nextState.filteredSlices = getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
this.state.searchTerm,
|
||||
this.state.sortBy,
|
||||
this.state.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
|
||||
}, [selectedSliceIdsSet]);
|
||||
|
||||
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
|
||||
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
|
||||
}
|
||||
|
||||
if (Object.keys(nextState).length) {
|
||||
this.setState(nextState);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Clears the redux store keeping only selected items
|
||||
const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
|
||||
this.state.selectedSliceIdsSet.has(value.slice_id),
|
||||
);
|
||||
|
||||
this.props.updateSlices(selectedSlices);
|
||||
if (this.slicesRequest instanceof AbortController) {
|
||||
this.slicesRequest.abort();
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = debounce(value => {
|
||||
this.searchUpdated(value);
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
this.userIdForFetch(),
|
||||
value,
|
||||
this.state.sortBy,
|
||||
);
|
||||
}, 300);
|
||||
|
||||
searchUpdated(searchTerm: string) {
|
||||
this.setState(prevState => ({
|
||||
searchTerm,
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
const filteredSlices = useMemo(
|
||||
() =>
|
||||
getFilteredSortedSlices(
|
||||
slices,
|
||||
searchTerm,
|
||||
prevState.sortBy,
|
||||
prevState.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
handleSelect(sortBy: keyof Slice) {
|
||||
this.setState(prevState => ({
|
||||
sortBy,
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
prevState.searchTerm,
|
||||
sortBy,
|
||||
prevState.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
),
|
||||
}));
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
this.userIdForFetch(),
|
||||
this.state.searchTerm,
|
||||
sortBy,
|
||||
);
|
||||
}
|
||||
|
||||
rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) {
|
||||
const { filteredSlices, selectedSliceIdsSet } = this.state;
|
||||
const cellData = filteredSlices[index];
|
||||
|
||||
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
||||
const type = CHART_TYPE;
|
||||
const id = NEW_CHART_ID;
|
||||
|
||||
const meta = {
|
||||
chartId: cellData.slice_id,
|
||||
sliceName: cellData.slice_name,
|
||||
};
|
||||
return (
|
||||
<DragDroppable
|
||||
key={cellData.slice_id}
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={index}
|
||||
depth={0}
|
||||
disableDragDrop={isSelected}
|
||||
editMode={this.props.editMode}
|
||||
// we must use a custom drag preview within the List because
|
||||
// it does not seem to work within a fixed-position container
|
||||
useEmptyDragPreview
|
||||
// List library expect style props here
|
||||
// actual style should be applied to nested AddSliceCard component
|
||||
style={{}}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<AddSliceCard
|
||||
innerRef={dragSourceRef}
|
||||
style={style}
|
||||
sliceName={cellData.slice_name}
|
||||
lastModified={cellData.changed_on_humanized}
|
||||
visType={cellData.viz_type}
|
||||
datasourceUrl={cellData.datasource_url}
|
||||
datasourceName={cellData.datasource_name}
|
||||
thumbnailUrl={cellData.thumbnail_url}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
}
|
||||
|
||||
onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => {
|
||||
if (!showOnlyMyCharts) {
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
undefined,
|
||||
this.state.searchTerm,
|
||||
this.state.sortBy,
|
||||
);
|
||||
}
|
||||
this.setState(prevState => ({
|
||||
showOnlyMyCharts,
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
prevState.searchTerm,
|
||||
prevState.sortBy,
|
||||
showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
userId,
|
||||
),
|
||||
}));
|
||||
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);
|
||||
};
|
||||
[slices, searchTerm, sortBy, showOnlyMyCharts, userId],
|
||||
);
|
||||
|
||||
render() {
|
||||
const { theme } = this.props;
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
button > span > :first-of-type {
|
||||
margin-right: 0;
|
||||
const userIdForFetch = useCallback(
|
||||
() => (showOnlyMyCharts ? userId : undefined),
|
||||
[showOnlyMyCharts, userId],
|
||||
);
|
||||
|
||||
// componentDidMount
|
||||
useEffect(() => {
|
||||
slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update selectedSliceIdsSet when selectedSliceIds prop changes
|
||||
useEffect(() => {
|
||||
setSelectedSliceIdsSet(new Set(selectedSliceIds));
|
||||
}, [selectedSliceIds]);
|
||||
|
||||
// componentWillUnmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Clears the redux store keeping only selected items
|
||||
// Use refs to get latest values on unmount
|
||||
const selectedSlices = pickBy(latestSlicesRef.current, (value: Slice) =>
|
||||
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
|
||||
);
|
||||
|
||||
updateSlices(selectedSlices);
|
||||
if (slicesRequestRef.current instanceof AbortController) {
|
||||
slicesRequestRef.current.abort();
|
||||
}
|
||||
},
|
||||
[updateSlices],
|
||||
);
|
||||
|
||||
const searchUpdated = useCallback((term: string) => {
|
||||
setSearchTerm(term);
|
||||
}, []);
|
||||
|
||||
const handleChange = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
searchUpdated(value);
|
||||
slicesRequestRef.current = fetchSlices(userIdForFetch(), value, sortBy);
|
||||
}, 300),
|
||||
[fetchSlices, searchUpdated, sortBy, userIdForFetch],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleChange.cancel();
|
||||
},
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(newSortBy: keyof Slice) => {
|
||||
setSortBy(newSortBy);
|
||||
slicesRequestRef.current = fetchSlices(
|
||||
userIdForFetch(),
|
||||
searchTerm,
|
||||
newSortBy,
|
||||
);
|
||||
},
|
||||
[fetchSlices, searchTerm, userIdForFetch],
|
||||
);
|
||||
|
||||
const onShowOnlyMyCharts = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!checked) {
|
||||
slicesRequestRef.current = fetchSlices(undefined, searchTerm, sortBy);
|
||||
}
|
||||
setShowOnlyMyCharts(checked);
|
||||
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, checked);
|
||||
},
|
||||
[fetchSlices, searchTerm, sortBy],
|
||||
);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const cellData = filteredSlices[index];
|
||||
|
||||
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
||||
const type = CHART_TYPE;
|
||||
const id = NEW_CHART_ID;
|
||||
|
||||
const meta = {
|
||||
chartId: cellData.slice_id,
|
||||
sliceName: cellData.slice_name,
|
||||
};
|
||||
return (
|
||||
<DragDroppable
|
||||
key={cellData.slice_id}
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={index}
|
||||
depth={0}
|
||||
disableDragDrop={isSelected}
|
||||
editMode={editMode}
|
||||
// we must use a custom drag preview within the List because
|
||||
// it does not seem to work within a fixed-position container
|
||||
useEmptyDragPreview
|
||||
// List library expect style props here
|
||||
// actual style should be applied to nested AddSliceCard component
|
||||
style={{}}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<AddSliceCard
|
||||
innerRef={dragSourceRef}
|
||||
style={style}
|
||||
sliceName={cellData.slice_name}
|
||||
lastModified={cellData.changed_on_humanized}
|
||||
visType={cellData.viz_type}
|
||||
datasourceUrl={cellData.datasource_url}
|
||||
datasourceName={cellData.datasource_name}
|
||||
thumbnailUrl={cellData.thumbnail_url}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
},
|
||||
[filteredSlices, selectedSliceIdsSet, editMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
button > span > :first-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<NewChartButtonContainer>
|
||||
<NewChartButton
|
||||
buttonStyle="link"
|
||||
buttonSize="xsmall"
|
||||
icon={
|
||||
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
|
||||
}
|
||||
onClick={() =>
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Create new chart')}
|
||||
</NewChartButton>
|
||||
</NewChartButtonContainer>
|
||||
<Controls>
|
||||
<Input
|
||||
placeholder={
|
||||
showOnlyMyCharts ? t('Filter your charts') : t('Filter charts')
|
||||
}
|
||||
className="search-input"
|
||||
onChange={ev => handleChange(ev.target.value)}
|
||||
data-test="dashboard-charts-filter-search-input"
|
||||
/>
|
||||
<StyledSelect
|
||||
id="slice-adder-sortby"
|
||||
value={sortBy}
|
||||
onChange={handleSelect}
|
||||
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
|
||||
label: t('Sort by %s', label),
|
||||
value: key,
|
||||
}))}
|
||||
placeholder={t('Sort by')}
|
||||
/>
|
||||
</Controls>
|
||||
<div
|
||||
css={themeObj => css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: ${themeObj.sizeUnit}px;
|
||||
padding: 0 ${themeObj.sizeUnit * 3}px ${themeObj.sizeUnit * 4}px
|
||||
${themeObj.sizeUnit * 3}px;
|
||||
`}
|
||||
>
|
||||
<NewChartButtonContainer>
|
||||
<NewChartButton
|
||||
buttonStyle="link"
|
||||
buttonSize="xsmall"
|
||||
icon={
|
||||
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
|
||||
}
|
||||
onClick={() =>
|
||||
navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, {
|
||||
newWindow: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Create new chart')}
|
||||
</NewChartButton>
|
||||
</NewChartButtonContainer>
|
||||
<Controls>
|
||||
<Input
|
||||
placeholder={
|
||||
this.state.showOnlyMyCharts
|
||||
? t('Filter your charts')
|
||||
: t('Filter charts')
|
||||
}
|
||||
className="search-input"
|
||||
onChange={ev => this.handleChange(ev.target.value)}
|
||||
data-test="dashboard-charts-filter-search-input"
|
||||
/>
|
||||
<StyledSelect
|
||||
id="slice-adder-sortby"
|
||||
value={this.state.sortBy}
|
||||
onChange={this.handleSelect}
|
||||
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
|
||||
label: t('Sort by %s', label),
|
||||
value: key,
|
||||
}))}
|
||||
placeholder={t('Sort by')}
|
||||
/>
|
||||
</Controls>
|
||||
<Checkbox
|
||||
onChange={e => onShowOnlyMyCharts(e.target.checked)}
|
||||
checked={showOnlyMyCharts}
|
||||
/>
|
||||
{t('Show only my charts')}
|
||||
<InfoTooltip
|
||||
placement="top"
|
||||
tooltip={t(
|
||||
`You can choose to display all charts that you have access to or only the ones you own.
|
||||
Your filter selection will be saved and remain active until you choose to change it.`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <Loading />}
|
||||
{!isLoading && filteredSlices.length > 0 && (
|
||||
<ChartList>
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={filteredSlices.length}
|
||||
itemSize={DEFAULT_CELL_HEIGHT}
|
||||
itemKey={index => filteredSlices[index].slice_id}
|
||||
>
|
||||
{rowRenderer}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</ChartList>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div
|
||||
css={theme => css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px
|
||||
${theme.sizeUnit * 3}px;
|
||||
css={css`
|
||||
padding: 16px;
|
||||
`}
|
||||
>
|
||||
<Checkbox
|
||||
onChange={e => this.onShowOnlyMyCharts(e.target.checked)}
|
||||
checked={this.state.showOnlyMyCharts}
|
||||
/>
|
||||
{t('Show only my charts')}
|
||||
<InfoTooltip
|
||||
placement="top"
|
||||
tooltip={t(
|
||||
`You can choose to display all charts that you have access to or only the ones you own.
|
||||
Your filter selection will be saved and remain active until you choose to change it.`,
|
||||
)}
|
||||
/>
|
||||
{errorMessage}
|
||||
</div>
|
||||
{this.props.isLoading && <Loading />}
|
||||
{!this.props.isLoading && this.state.filteredSlices.length > 0 && (
|
||||
<ChartList>
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={this.state.filteredSlices.length}
|
||||
itemSize={DEFAULT_CELL_HEIGHT}
|
||||
itemKey={index => this.state.filteredSlices[index].slice_id}
|
||||
>
|
||||
{this.rowRenderer}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</ChartList>
|
||||
)}
|
||||
{this.props.errorMessage && (
|
||||
<div
|
||||
css={css`
|
||||
padding: 16px;
|
||||
`}
|
||||
>
|
||||
{this.props.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{/* Drag preview is just a single fixed-position element */}
|
||||
<AddSliceDragPreview slices={this.state.filteredSlices} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{/* Drag preview is just a single fixed-position element */}
|
||||
<AddSliceDragPreview slices={filteredSlices} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTheme(SliceAdder);
|
||||
export default SliceAdder;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { HeaderProps } from '../Header/types';
|
||||
|
||||
type UndoRedoKeyListenersProps = {
|
||||
@@ -24,43 +24,39 @@ type UndoRedoKeyListenersProps = {
|
||||
onRedo: HeaderProps['onRedo'];
|
||||
};
|
||||
|
||||
class UndoRedoKeyListeners extends PureComponent<UndoRedoKeyListenersProps> {
|
||||
constructor(props: UndoRedoKeyListenersProps) {
|
||||
super(props);
|
||||
this.handleKeydown = this.handleKeydown.bind(this);
|
||||
}
|
||||
function UndoRedoKeyListeners({ onUndo, onRedo }: UndoRedoKeyListenersProps) {
|
||||
const handleKeydown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const controlOrCommand = event.ctrlKey || event.metaKey;
|
||||
if (controlOrCommand) {
|
||||
const key = event.key.toLowerCase();
|
||||
const isUndo = key === 'z' && !event.shiftKey;
|
||||
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
|
||||
const isEditingMarkdown = document?.querySelector(
|
||||
'.dashboard-markdown--editing',
|
||||
);
|
||||
const isEditingTitle = document?.querySelector(
|
||||
'.editable-title--editing',
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
handleKeydown(event: KeyboardEvent) {
|
||||
const controlOrCommand = event.ctrlKey || event.metaKey;
|
||||
if (controlOrCommand) {
|
||||
const isZChar = event.key === 'z' || event.keyCode === 90;
|
||||
const isYChar = event.key === 'y' || event.keyCode === 89;
|
||||
const isEditingMarkdown = document?.querySelector(
|
||||
'.dashboard-markdown--editing',
|
||||
);
|
||||
const isEditingTitle = document?.querySelector(
|
||||
'.editable-title--editing',
|
||||
);
|
||||
|
||||
if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) {
|
||||
event.preventDefault();
|
||||
const func = isZChar ? this.props.onUndo : this.props.onRedo;
|
||||
func();
|
||||
if (!isEditingMarkdown && !isEditingTitle && (isUndo || isRedo)) {
|
||||
event.preventDefault();
|
||||
const func = isUndo ? onUndo : onRedo;
|
||||
func();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[onUndo, onRedo],
|
||||
);
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [handleKeydown]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default UndoRedoKeyListeners;
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
} from 'react-dnd';
|
||||
import cx from 'classnames';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
import { dragConfig, dropConfig } from './dragDroppableConfig';
|
||||
import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig';
|
||||
import { DROP_FORBIDDEN } from '../../util/getDropPosition';
|
||||
@@ -92,15 +91,6 @@ const DragDroppableStyles = styled.div`
|
||||
&.dragdroppable-row {
|
||||
width: 100%;
|
||||
}
|
||||
/* workaround to avoid a bug in react-dnd where the drag
|
||||
preview expands outside of the bounds of the drag source card, see:
|
||||
https://github.com/react-dnd/react-dnd/issues/832 */
|
||||
&.dragdroppable-column {
|
||||
/* for chrome */
|
||||
transform: translate3d(0, 0, 0);
|
||||
/* for safari */
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
&.dragdroppable-column .resizable-container span div {
|
||||
z-index: 10;
|
||||
@@ -122,15 +112,22 @@ const DragDroppableStyles = styled.div`
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
/**
|
||||
* Note: This component remains a class component because it is tightly integrated
|
||||
* with react-dnd's class-based HOC system (DragSource/DropTarget). The HOCs
|
||||
* access component instance properties directly (mounted, ref, props, setState)
|
||||
* in the hover/drop callbacks defined in dragDroppableConfig.ts.
|
||||
*
|
||||
* Converting to a function component would require migrating to react-dnd's
|
||||
* hooks API (useDrag/useDrop), which would be a more extensive refactor.
|
||||
*/
|
||||
// export unwrapped component for testing
|
||||
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- react-dnd class-based HOC requires class component instance properties
|
||||
export class UnwrappedDragDroppable extends PureComponent<
|
||||
DragDroppableAllProps,
|
||||
DragDroppableState
|
||||
> {
|
||||
mounted: boolean;
|
||||
|
||||
ref: HTMLDivElement | null;
|
||||
|
||||
static defaultProps = {
|
||||
className: null,
|
||||
style: null,
|
||||
@@ -152,6 +149,10 @@ export class UnwrappedDragDroppable extends PureComponent<
|
||||
dragPreviewRef() {},
|
||||
};
|
||||
|
||||
mounted: boolean;
|
||||
|
||||
ref: HTMLDivElement | null;
|
||||
|
||||
constructor(props: DragDroppableAllProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -283,7 +284,6 @@ export class UnwrappedDragDroppable extends PureComponent<
|
||||
|
||||
// react-dnd's DragSource/DropTarget HOC types don't play well with
|
||||
// class components using spread config tuples, so we use type assertions here
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const DragDroppableAsAny =
|
||||
UnwrappedDragDroppable as unknown as ReactComponentType<
|
||||
Record<string, unknown>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, PureComponent } from 'react';
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import {
|
||||
ModalTrigger,
|
||||
@@ -33,39 +33,29 @@ const FilterScopeModalBody = styled.div(({ theme: { sizeUnit } }) => ({
|
||||
paddingBottom: sizeUnit * 3,
|
||||
}));
|
||||
|
||||
export default class FilterScopeModal extends PureComponent<
|
||||
FilterScopeModalProps,
|
||||
{}
|
||||
> {
|
||||
modal: ModalTriggerRef;
|
||||
export default function FilterScopeModal({
|
||||
triggerNode,
|
||||
}: FilterScopeModalProps) {
|
||||
const modalRef = useRef<ModalTriggerRef['current']>(null);
|
||||
|
||||
constructor(props: FilterScopeModalProps) {
|
||||
super(props);
|
||||
const handleCloseModal = useCallback((): void => {
|
||||
modalRef.current?.close?.();
|
||||
}, []);
|
||||
|
||||
this.modal = createRef() as ModalTriggerRef;
|
||||
this.handleCloseModal = this.handleCloseModal.bind(this);
|
||||
}
|
||||
const filterScopeProps = {
|
||||
onCloseModal: handleCloseModal,
|
||||
};
|
||||
|
||||
handleCloseModal(): void {
|
||||
this?.modal?.current?.close?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const filterScopeProps = {
|
||||
onCloseModal: this.handleCloseModal,
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalTrigger
|
||||
ref={this.modal}
|
||||
triggerNode={this.props.triggerNode}
|
||||
modalBody={
|
||||
<FilterScopeModalBody>
|
||||
<FilterScope {...filterScopeProps} />
|
||||
</FilterScopeModalBody>
|
||||
}
|
||||
width="80%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ModalTrigger
|
||||
ref={modalRef}
|
||||
triggerNode={triggerNode}
|
||||
modalBody={
|
||||
<FilterScopeModalBody>
|
||||
<FilterScope {...filterScopeProps} />
|
||||
</FilterScopeModalBody>
|
||||
}
|
||||
width="80%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you 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 {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import FilterScopeSelector from './FilterScopeSelector';
|
||||
import type { DashboardLayout } from 'src/dashboard/types';
|
||||
|
||||
// --- Mock child components ---
|
||||
|
||||
jest.mock('./FilterFieldTree', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-test="filter-field-tree">
|
||||
FilterFieldTree (checked={String(props.checked)})
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('./FilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-test="filter-scope-tree">
|
||||
FilterScopeTree (checked={String(props.checked)})
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// --- Mock utility functions ---
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterFieldNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => [
|
||||
{
|
||||
value: 'ALL_FILTERS_ROOT',
|
||||
label: 'All filters',
|
||||
children: [
|
||||
{
|
||||
value: 1,
|
||||
label: 'Filter A',
|
||||
children: [
|
||||
{ value: '1_column_b', label: 'Filter B' },
|
||||
{ value: '1_column_c', label: 'Filter C' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => [
|
||||
{
|
||||
value: 'ROOT_ID',
|
||||
label: 'All charts',
|
||||
children: [{ value: 2, label: 'Chart A' }],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeParentNodes', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ['ROOT_ID']),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/buildFilterScopeTreeEntry', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getKeyForFilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => '1_column_b'),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getSelectedChartIdForFilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => 1),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeFromNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ scope: ['ROOT_ID'], immune: [] })),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getRevertedFilterScope', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
|
||||
getChartIdsInFilterScope: jest.fn(() => [2, 3]),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockDashboardFilters = {
|
||||
1: {
|
||||
chartId: 1,
|
||||
componentId: 'component-1',
|
||||
filterName: 'Filter A',
|
||||
datasourceId: 'ds-1',
|
||||
directPathToFilter: ['ROOT_ID', 'GRID', 'CHART_1'],
|
||||
isDateFilter: false,
|
||||
isInstantFilter: false,
|
||||
columns: { column_b: undefined, column_c: undefined },
|
||||
labels: { column_b: 'Filter B', column_c: 'Filter C' },
|
||||
scopes: {
|
||||
column_b: { immune: [], scope: ['ROOT_ID'] },
|
||||
column_c: { immune: [], scope: ['ROOT_ID'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockLayout: DashboardLayout = {
|
||||
ROOT_ID: { children: ['GRID'], id: 'ROOT_ID', type: 'ROOT' },
|
||||
GRID: {
|
||||
children: ['CHART_1', 'CHART_2'],
|
||||
id: 'GRID',
|
||||
type: 'GRID',
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
CHART_1: {
|
||||
meta: { chartId: 1, sliceName: 'Chart 1' },
|
||||
children: [],
|
||||
id: 'CHART_1',
|
||||
type: 'CHART',
|
||||
parents: ['ROOT_ID', 'GRID'],
|
||||
},
|
||||
CHART_2: {
|
||||
meta: { chartId: 2, sliceName: 'Chart 2' },
|
||||
children: [],
|
||||
id: 'CHART_2',
|
||||
type: 'CHART',
|
||||
parents: ['ROOT_ID', 'GRID'],
|
||||
},
|
||||
} as unknown as DashboardLayout;
|
||||
|
||||
const defaultProps = {
|
||||
dashboardFilters: mockDashboardFilters,
|
||||
layout: mockLayout,
|
||||
updateDashboardFiltersScope: jest.fn(),
|
||||
setUnsavedChanges: jest.fn(),
|
||||
onCloseModal: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders the header, filter field panel, and scope panel', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByText('Configure filter scopes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-field-tree')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-scope-tree')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the search input with correct placeholder', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchInput).toHaveAttribute('type', 'text');
|
||||
});
|
||||
|
||||
test('renders Close and Save buttons when filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only Close button and a warning when no filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('There are no filters in this dashboard.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Save' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render FilterFieldTree or FilterScopeTree when no filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('filter-field-tree')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('filter-scope-tree')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onCloseModal when Close button is clicked', () => {
|
||||
const onCloseModal = jest.fn();
|
||||
render(
|
||||
<FilterScopeSelector {...defaultProps} onCloseModal={onCloseModal} />,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
expect(onCloseModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('calls updateDashboardFiltersScope, setUnsavedChanges, and onCloseModal when Save is clicked', () => {
|
||||
const updateDashboardFiltersScope = jest.fn();
|
||||
const setUnsavedChanges = jest.fn();
|
||||
const onCloseModal = jest.fn();
|
||||
|
||||
render(
|
||||
<FilterScopeSelector
|
||||
{...defaultProps}
|
||||
updateDashboardFiltersScope={updateDashboardFiltersScope}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
onCloseModal={onCloseModal}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(updateDashboardFiltersScope).toHaveBeenCalledTimes(1);
|
||||
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
|
||||
expect(onCloseModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders the editing filters name section with "Editing 1 filter:" label', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByText('Editing 1 filter:')).toBeInTheDocument();
|
||||
// The active filter label should appear (column_b maps to "Filter B")
|
||||
expect(screen.getByText('Filter B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates search text when typing in the search input', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
userEvent.type(searchInput, 'Chart');
|
||||
|
||||
expect(searchInput).toHaveValue('Chart');
|
||||
});
|
||||
@@ -16,12 +16,17 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent, ChangeEvent, type ReactElement } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
ChangeEvent,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button, Input } from '@superset-ui/core/components';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry';
|
||||
import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree';
|
||||
import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree';
|
||||
@@ -90,30 +95,6 @@ export interface FilterScopeSelectorProps {
|
||||
onCloseModal: () => void;
|
||||
}
|
||||
|
||||
interface FilterScopeSelectorStateWithSelector {
|
||||
showSelector: true;
|
||||
activeFilterField: string | null;
|
||||
searchText: string;
|
||||
filterScopeMap: FilterScopeMap;
|
||||
filterFieldNodes: FilterFieldNode[];
|
||||
checkedFilterFields: string[];
|
||||
expandedFilterIds: (string | number)[];
|
||||
}
|
||||
|
||||
interface FilterScopeSelectorStateWithoutSelector {
|
||||
showSelector: false;
|
||||
activeFilterField?: undefined;
|
||||
searchText?: undefined;
|
||||
filterScopeMap?: undefined;
|
||||
filterFieldNodes?: undefined;
|
||||
checkedFilterFields?: undefined;
|
||||
expandedFilterIds?: undefined;
|
||||
}
|
||||
|
||||
type FilterScopeSelectorState =
|
||||
| FilterScopeSelectorStateWithSelector
|
||||
| FilterScopeSelectorStateWithoutSelector;
|
||||
|
||||
const ScopeContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
@@ -389,271 +370,358 @@ const ActionsContainer = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
export default class FilterScopeSelector extends PureComponent<
|
||||
FilterScopeSelectorProps,
|
||||
FilterScopeSelectorState
|
||||
> {
|
||||
allfilterFields: string[];
|
||||
function initializeState(
|
||||
dashboardFilters: Record<number, DashboardFilter>,
|
||||
layout: DashboardLayout,
|
||||
) {
|
||||
if (Object.keys(dashboardFilters).length === 0) {
|
||||
return {
|
||||
showSelector: false as const,
|
||||
allFilterFields: [] as string[],
|
||||
defaultFilterKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
defaultFilterKey: string;
|
||||
// display filter fields in tree structure
|
||||
const filterFieldNodes = getFilterFieldNodesTree({
|
||||
dashboardFilters,
|
||||
});
|
||||
// filterFieldNodes root node is dashboard_root component,
|
||||
// so that we can offer a select/deselect all link
|
||||
const filtersNodes = filterFieldNodes[0].children ?? [];
|
||||
const allFilterFields: string[] = [];
|
||||
filtersNodes.forEach(({ children }) => {
|
||||
(children ?? []).forEach(child => {
|
||||
allFilterFields.push(String(child.value));
|
||||
});
|
||||
});
|
||||
const defaultFilterKey = String(filtersNodes[0]?.children?.[0]?.value ?? '');
|
||||
|
||||
constructor(props: FilterScopeSelectorProps) {
|
||||
super(props);
|
||||
|
||||
this.allfilterFields = [];
|
||||
this.defaultFilterKey = '';
|
||||
|
||||
const { dashboardFilters, layout } = props;
|
||||
|
||||
if (Object.keys(dashboardFilters).length > 0) {
|
||||
// display filter fields in tree structure
|
||||
const filterFieldNodes = getFilterFieldNodesTree({
|
||||
dashboardFilters,
|
||||
});
|
||||
// filterFieldNodes root node is dashboard_root component,
|
||||
// so that we can offer a select/deselect all link
|
||||
const filtersNodes = filterFieldNodes[0].children ?? [];
|
||||
this.allfilterFields = [];
|
||||
filtersNodes.forEach(({ children }) => {
|
||||
(children ?? []).forEach(child => {
|
||||
this.allfilterFields.push(String(child.value));
|
||||
// build FilterScopeTree object for each filterKey
|
||||
const filterScopeMap: FilterScopeMap = Object.values(
|
||||
dashboardFilters,
|
||||
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
|
||||
const filterScopeByChartId = Object.keys(columns).reduce<FilterScopeMap>(
|
||||
(mapByChartId, columnName) => {
|
||||
const filterKey = getDashboardFilterKey({
|
||||
chartId: String(filterId),
|
||||
column: columnName,
|
||||
});
|
||||
});
|
||||
this.defaultFilterKey = String(
|
||||
filtersNodes[0]?.children?.[0]?.value ?? '',
|
||||
);
|
||||
|
||||
// build FilterScopeTree object for each filterKey
|
||||
const filterScopeMap: FilterScopeMap = Object.values(
|
||||
dashboardFilters,
|
||||
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
|
||||
const filterScopeByChartId = Object.keys(
|
||||
columns,
|
||||
).reduce<FilterScopeMap>((mapByChartId, columnName) => {
|
||||
const filterKey = getDashboardFilterKey({
|
||||
chartId: String(filterId),
|
||||
column: columnName,
|
||||
});
|
||||
const nodes = getFilterScopeNodesTree({
|
||||
components: layout,
|
||||
filterFields: [filterKey],
|
||||
selectedChartId: filterId,
|
||||
});
|
||||
const expanded = getFilterScopeParentNodes(nodes, 1);
|
||||
const chartIdsInFilterScope = (
|
||||
getChartIdsInFilterScope({
|
||||
filterScope: dashboardFilters[filterId].scopes[columnName],
|
||||
}) || []
|
||||
).filter((id: number) => id !== filterId);
|
||||
|
||||
return {
|
||||
...mapByChartId,
|
||||
[filterKey]: {
|
||||
// unfiltered nodes
|
||||
nodes,
|
||||
// filtered nodes in display if searchText is not empty
|
||||
nodesFiltered: [...nodes],
|
||||
checked: chartIdsInFilterScope,
|
||||
expanded,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
const nodes = getFilterScopeNodesTree({
|
||||
components: layout,
|
||||
filterFields: [filterKey],
|
||||
selectedChartId: filterId,
|
||||
});
|
||||
const expanded = getFilterScopeParentNodes(nodes, 1);
|
||||
const chartIdsInFilterScope = (
|
||||
getChartIdsInFilterScope({
|
||||
filterScope: dashboardFilters[filterId].scopes[columnName],
|
||||
}) || []
|
||||
).filter((id: number) => id !== filterId);
|
||||
|
||||
return {
|
||||
...map,
|
||||
...filterScopeByChartId,
|
||||
...mapByChartId,
|
||||
[filterKey]: {
|
||||
// unfiltered nodes
|
||||
nodes,
|
||||
// filtered nodes in display if searchText is not empty
|
||||
nodesFiltered: [...nodes],
|
||||
checked: chartIdsInFilterScope,
|
||||
expanded,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
|
||||
// initial state: active defaultFilerKey
|
||||
const { chartId } = getChartIdAndColumnFromFilterKey(
|
||||
this.defaultFilterKey,
|
||||
);
|
||||
const checkedFilterFields: string[] = [];
|
||||
const activeFilterField = this.defaultFilterKey;
|
||||
// expand defaultFilterKey in filter field tree
|
||||
const expandedFilterIds: (string | number)[] = [
|
||||
ALL_FILTERS_ROOT,
|
||||
chartId,
|
||||
];
|
||||
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
this.state = {
|
||||
showSelector: true,
|
||||
activeFilterField,
|
||||
searchText: '',
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
} as FilterScopeMap,
|
||||
filterFieldNodes,
|
||||
checkedFilterFields,
|
||||
expandedFilterIds,
|
||||
};
|
||||
} else {
|
||||
this.state = {
|
||||
showSelector: false,
|
||||
};
|
||||
}
|
||||
|
||||
this.filterNodes = this.filterNodes.bind(this);
|
||||
this.onChangeFilterField = this.onChangeFilterField.bind(this);
|
||||
this.onCheckFilterScope = this.onCheckFilterScope.bind(this);
|
||||
this.onExpandFilterScope = this.onExpandFilterScope.bind(this);
|
||||
this.onSearchInputChange = this.onSearchInputChange.bind(this);
|
||||
this.onCheckFilterField = this.onCheckFilterField.bind(this);
|
||||
this.onExpandFilterField = this.onExpandFilterField.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onSave = this.onSave.bind(this);
|
||||
}
|
||||
|
||||
onCheckFilterScope(checked: (string | number)[] = []): void {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, filterScopeMap, checkedFilterFields } = state;
|
||||
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
const editingList = activeFilterField
|
||||
? [activeFilterField]
|
||||
: checkedFilterFields;
|
||||
const updatedEntry = {
|
||||
...filterScopeMap[key],
|
||||
checked,
|
||||
};
|
||||
|
||||
const updatedFilterScopeMap = getRevertedFilterScope({
|
||||
checked,
|
||||
filterFields: editingList,
|
||||
filterScopeMap,
|
||||
});
|
||||
|
||||
this.setState(() => ({
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
...updatedFilterScopeMap,
|
||||
[key]: updatedEntry,
|
||||
} as FilterScopeMap,
|
||||
}));
|
||||
}
|
||||
|
||||
onExpandFilterScope(expanded: string[] = []): void {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = state;
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
const updatedEntry = {
|
||||
...filterScopeMap[key],
|
||||
expanded,
|
||||
};
|
||||
this.setState(() => ({
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
[key]: updatedEntry,
|
||||
},
|
||||
}));
|
||||
}
|
||||
{},
|
||||
);
|
||||
|
||||
onCheckFilterField(checkedFilterFields: string[] = []): void {
|
||||
const { layout } = this.props;
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { filterScopeMap } = state;
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField: undefined,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
return {
|
||||
...map,
|
||||
...filterScopeByChartId,
|
||||
};
|
||||
}, {});
|
||||
|
||||
this.setState(() => ({
|
||||
activeFilterField: null,
|
||||
checkedFilterFields,
|
||||
// initial state: active defaultFilerKey
|
||||
const { chartId } = getChartIdAndColumnFromFilterKey(defaultFilterKey);
|
||||
const checkedFilterFields: string[] = [];
|
||||
const activeFilterField = defaultFilterKey;
|
||||
// expand defaultFilterKey in filter field tree
|
||||
const expandedFilterIds: (string | number)[] = [ALL_FILTERS_ROOT, chartId];
|
||||
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
|
||||
return {
|
||||
showSelector: true as const,
|
||||
allFilterFields,
|
||||
defaultFilterKey,
|
||||
initialState: {
|
||||
activeFilterField,
|
||||
searchText: '',
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
onExpandFilterField(expandedFilterIds: (string | number)[] = []): void {
|
||||
this.setState(() => ({
|
||||
expandedFilterIds,
|
||||
}));
|
||||
}
|
||||
|
||||
onChangeFilterField(filterField: { value?: string } = {}): void {
|
||||
const { layout } = this.props;
|
||||
const nextActiveFilterField = filterField.value;
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const {
|
||||
activeFilterField: currentActiveFilterField,
|
||||
} as FilterScopeMap,
|
||||
filterFieldNodes,
|
||||
checkedFilterFields,
|
||||
filterScopeMap,
|
||||
} = state;
|
||||
expandedFilterIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// we allow single edit and multiple edit in the same view.
|
||||
// if user click on the single filter field,
|
||||
// will show filter scope for the single field.
|
||||
// if user click on the same filter filed again,
|
||||
// will toggle off the single filter field,
|
||||
// and allow multi-edit all checked filter fields.
|
||||
if (nextActiveFilterField === currentActiveFilterField) {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
export default function FilterScopeSelector({
|
||||
dashboardFilters,
|
||||
layout,
|
||||
updateDashboardFiltersScope,
|
||||
setUnsavedChanges,
|
||||
onCloseModal,
|
||||
}: FilterScopeSelectorProps): ReactElement {
|
||||
const initialized = useMemo(
|
||||
() => initializeState(dashboardFilters, layout),
|
||||
// Only initialize once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const { showSelector, allFilterFields } = initialized;
|
||||
|
||||
const [activeFilterField, setActiveFilterField] = useState<string | null>(
|
||||
() =>
|
||||
initialized.showSelector
|
||||
? initialized.initialState.activeFilterField
|
||||
: null,
|
||||
);
|
||||
const [searchText, setSearchText] = useState(() =>
|
||||
initialized.showSelector ? initialized.initialState.searchText : '',
|
||||
);
|
||||
const [filterScopeMap, setFilterScopeMap] = useState<FilterScopeMap>(() =>
|
||||
initialized.showSelector ? initialized.initialState.filterScopeMap : {},
|
||||
);
|
||||
const [filterFieldNodes] = useState<FilterFieldNode[]>(() =>
|
||||
initialized.showSelector ? initialized.initialState.filterFieldNodes : [],
|
||||
);
|
||||
const [checkedFilterFields, setCheckedFilterFields] = useState<string[]>(
|
||||
() =>
|
||||
initialized.showSelector
|
||||
? initialized.initialState.checkedFilterFields
|
||||
: [],
|
||||
);
|
||||
const [expandedFilterIds, setExpandedFilterIds] = useState<
|
||||
(string | number)[]
|
||||
>(() =>
|
||||
initialized.showSelector ? initialized.initialState.expandedFilterIds : [],
|
||||
);
|
||||
|
||||
const filterNodes = useCallback(
|
||||
(
|
||||
filtered: FilterScopeTreeNode[] = [],
|
||||
node: FilterScopeTreeNode = { value: '', label: '' },
|
||||
currentSearchText: string,
|
||||
): FilterScopeTreeNode[] => {
|
||||
const filterNodesRecursive = (
|
||||
f: FilterScopeTreeNode[],
|
||||
n: FilterScopeTreeNode,
|
||||
): FilterScopeTreeNode[] => filterNodes(f, n, currentSearchText);
|
||||
|
||||
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
|
||||
filterNodesRecursive,
|
||||
[],
|
||||
);
|
||||
|
||||
if (
|
||||
// Node's label matches the search string
|
||||
node.label
|
||||
.toLocaleLowerCase()
|
||||
.indexOf((currentSearchText ?? '').toLocaleLowerCase()) > -1 ||
|
||||
// Or a children has a matching node
|
||||
children.length
|
||||
) {
|
||||
filtered.push({ ...node, children });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filterTree = useCallback(
|
||||
(currentSearchText: string) => {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
|
||||
// Reset nodes back to unfiltered state
|
||||
if (!currentSearchText) {
|
||||
setFilterScopeMap(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
...prev[key],
|
||||
nodesFiltered: prev[key].nodes,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setFilterScopeMap(prev => {
|
||||
const nodesFiltered = prev[key].nodes.reduce<FilterScopeTreeNode[]>(
|
||||
(filtered, node) => filterNodes(filtered, node, currentSearchText),
|
||||
[],
|
||||
);
|
||||
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...prev[key],
|
||||
nodesFiltered,
|
||||
expanded,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeFilterField, checkedFilterFields, filterNodes],
|
||||
);
|
||||
|
||||
const onCheckFilterScope = useCallback(
|
||||
(checked: (string | number)[] = []): void => {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
const editingList = activeFilterField
|
||||
? [activeFilterField]
|
||||
: checkedFilterFields;
|
||||
|
||||
const updatedFilterScopeMap = getRevertedFilterScope({
|
||||
checked,
|
||||
filterFields: editingList,
|
||||
filterScopeMap,
|
||||
});
|
||||
|
||||
setFilterScopeMap({
|
||||
...filterScopeMap,
|
||||
...updatedFilterScopeMap,
|
||||
[key]: {
|
||||
...filterScopeMap[key],
|
||||
checked,
|
||||
},
|
||||
} as FilterScopeMap);
|
||||
},
|
||||
[activeFilterField, checkedFilterFields, filterScopeMap],
|
||||
);
|
||||
|
||||
const onExpandFilterScope = useCallback(
|
||||
(expanded: string[] = []): void => {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
|
||||
setFilterScopeMap(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
...prev[key],
|
||||
expanded,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[activeFilterField, checkedFilterFields],
|
||||
);
|
||||
|
||||
const onCheckFilterField = useCallback(
|
||||
(newCheckedFilterFields: string[] = []): void => {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields: newCheckedFilterFields,
|
||||
activeFilterField: undefined,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
activeFilterField: null,
|
||||
filterScopeMap: {
|
||||
setActiveFilterField(null);
|
||||
setCheckedFilterFields(newCheckedFilterFields);
|
||||
setFilterScopeMap({
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
});
|
||||
},
|
||||
[filterScopeMap, layout],
|
||||
);
|
||||
|
||||
const onExpandFilterField = useCallback(
|
||||
(newExpandedFilterIds: (string | number)[] = []): void => {
|
||||
setExpandedFilterIds(newExpandedFilterIds);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onChangeFilterField = useCallback(
|
||||
(filterField: { value?: string } = {}): void => {
|
||||
const nextActiveFilterField = filterField.value;
|
||||
|
||||
// we allow single edit and multiple edit in the same view.
|
||||
// if user click on the single filter field,
|
||||
// will show filter scope for the single field.
|
||||
// if user click on the same filter filed again,
|
||||
// will toggle off the single filter field,
|
||||
// and allow multi-edit all checked filter fields.
|
||||
if (nextActiveFilterField === activeFilterField) {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField: undefined,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
|
||||
setActiveFilterField(null);
|
||||
setFilterScopeMap({
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
} as FilterScopeMap,
|
||||
});
|
||||
} else if (
|
||||
nextActiveFilterField &&
|
||||
this.allfilterFields.includes(nextActiveFilterField)
|
||||
) {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField: nextActiveFilterField,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
});
|
||||
} else if (
|
||||
nextActiveFilterField &&
|
||||
allFilterFields.includes(nextActiveFilterField)
|
||||
) {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField: nextActiveFilterField,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
activeFilterField: nextActiveFilterField,
|
||||
filterScopeMap: {
|
||||
setActiveFilterField(nextActiveFilterField);
|
||||
setFilterScopeMap({
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
} as FilterScopeMap,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
activeFilterField,
|
||||
allFilterFields,
|
||||
checkedFilterFields,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
],
|
||||
);
|
||||
|
||||
onSearchInputChange(e: ChangeEvent<HTMLInputElement>): void {
|
||||
this.setState({ searchText: e.target.value }, this.filterTree);
|
||||
}
|
||||
const onSearchInputChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const newSearchText = e.target.value;
|
||||
setSearchText(newSearchText);
|
||||
filterTree(newSearchText);
|
||||
},
|
||||
[filterTree],
|
||||
);
|
||||
|
||||
onClose(): void {
|
||||
this.props.onCloseModal();
|
||||
}
|
||||
const onClose = useCallback((): void => {
|
||||
onCloseModal();
|
||||
}, [onCloseModal]);
|
||||
|
||||
onSave(): void {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { filterScopeMap } = state;
|
||||
|
||||
const allFilterFieldScopes = this.allfilterFields.reduce<
|
||||
const onSave = useCallback((): void => {
|
||||
const allFilterFieldScopes = allFilterFields.reduce<
|
||||
Record<string, ReturnType<typeof getFilterScopeFromNodesTree>>
|
||||
>((map, filterKey) => {
|
||||
const { nodes } = filterScopeMap[filterKey];
|
||||
@@ -669,124 +737,32 @@ export default class FilterScopeSelector extends PureComponent<
|
||||
};
|
||||
}, {});
|
||||
|
||||
this.props.updateDashboardFiltersScope(allFilterFieldScopes);
|
||||
this.props.setUnsavedChanges(true);
|
||||
updateDashboardFiltersScope(allFilterFieldScopes);
|
||||
setUnsavedChanges(true);
|
||||
|
||||
// click Save button will do save and close modal
|
||||
this.props.onCloseModal();
|
||||
}
|
||||
onCloseModal();
|
||||
}, [
|
||||
allFilterFields,
|
||||
filterScopeMap,
|
||||
onCloseModal,
|
||||
setUnsavedChanges,
|
||||
updateDashboardFiltersScope,
|
||||
]);
|
||||
|
||||
filterTree(): void {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
// Reset nodes back to unfiltered state
|
||||
if (!state.searchText) {
|
||||
this.setState(prevState => {
|
||||
const prev = prevState as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
|
||||
const updatedEntry = {
|
||||
...filterScopeMap[key],
|
||||
nodesFiltered: filterScopeMap[key].nodes,
|
||||
};
|
||||
return {
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
[key]: updatedEntry,
|
||||
},
|
||||
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
|
||||
});
|
||||
} else {
|
||||
const updater = (
|
||||
prevState: FilterScopeSelectorState,
|
||||
): FilterScopeSelectorState => {
|
||||
const prev = prevState as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
|
||||
const nodesFiltered = filterScopeMap[key].nodes.reduce<
|
||||
FilterScopeTreeNode[]
|
||||
>(this.filterNodes, []);
|
||||
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
|
||||
const updatedEntry = {
|
||||
...filterScopeMap[key],
|
||||
nodesFiltered,
|
||||
expanded,
|
||||
};
|
||||
|
||||
return {
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
[key]: updatedEntry,
|
||||
},
|
||||
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
|
||||
};
|
||||
|
||||
this.setState(updater);
|
||||
}
|
||||
}
|
||||
|
||||
filterNodes(
|
||||
filtered: FilterScopeTreeNode[] = [],
|
||||
node: FilterScopeTreeNode = { value: '', label: '' },
|
||||
): FilterScopeTreeNode[] {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { searchText } = state;
|
||||
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
|
||||
this.filterNodes,
|
||||
[],
|
||||
);
|
||||
|
||||
if (
|
||||
// Node's label matches the search string
|
||||
node.label
|
||||
.toLocaleLowerCase()
|
||||
.indexOf((searchText ?? '').toLocaleLowerCase()) > -1 ||
|
||||
// Or a children has a matching node
|
||||
children.length
|
||||
) {
|
||||
filtered.push({ ...node, children });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
renderFilterFieldList(): ReactElement | null {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const {
|
||||
activeFilterField,
|
||||
filterFieldNodes,
|
||||
checkedFilterFields,
|
||||
expandedFilterIds,
|
||||
} = state;
|
||||
return (
|
||||
<FilterFieldTree
|
||||
activeKey={activeFilterField}
|
||||
nodes={filterFieldNodes}
|
||||
checked={checkedFilterFields}
|
||||
expanded={expandedFilterIds}
|
||||
onClick={this.onChangeFilterField}
|
||||
onCheck={this.onCheckFilterField}
|
||||
onExpand={this.onExpandFilterField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderFilterScopeTree(): ReactElement {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const {
|
||||
filterScopeMap,
|
||||
activeFilterField,
|
||||
checkedFilterFields,
|
||||
searchText,
|
||||
} = state;
|
||||
const renderFilterFieldList = (): ReactElement | null => (
|
||||
<FilterFieldTree
|
||||
activeKey={activeFilterField}
|
||||
nodes={filterFieldNodes}
|
||||
checked={checkedFilterFields}
|
||||
expanded={expandedFilterIds}
|
||||
onClick={onChangeFilterField}
|
||||
onCheck={onCheckFilterField}
|
||||
onExpand={onExpandFilterField}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderFilterScopeTree = (): ReactElement => {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
@@ -803,26 +779,23 @@ export default class FilterScopeSelector extends PureComponent<
|
||||
placeholder={t('Search...')}
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={this.onSearchInputChange}
|
||||
onChange={onSearchInputChange}
|
||||
/>
|
||||
<FilterScopeTree
|
||||
nodes={filterScopeMap[key].nodesFiltered}
|
||||
checked={filterScopeMap[key].checked}
|
||||
expanded={filterScopeMap[key].expanded}
|
||||
onCheck={this.onCheckFilterScope}
|
||||
onExpand={this.onExpandFilterScope}
|
||||
onCheck={onCheckFilterScope}
|
||||
onExpand={onExpandFilterScope}
|
||||
// pass selectedFilterId prop to FilterScopeTree component,
|
||||
// to hide checkbox for selected filter field itself
|
||||
selectedChartId={selectedChartId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderEditingFiltersName(): ReactElement {
|
||||
const { dashboardFilters } = this.props;
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, checkedFilterFields } = state;
|
||||
const renderEditingFiltersName = (): ReactElement => {
|
||||
const currentFilterLabels = ([] as string[])
|
||||
.concat(activeFilterField || checkedFilterFields)
|
||||
.filter(Boolean)
|
||||
@@ -842,50 +815,42 @@ export default class FilterScopeSelector extends PureComponent<
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render(): ReactElement {
|
||||
const { showSelector } = this.state;
|
||||
return (
|
||||
<ScopeContainer>
|
||||
<ScopeHeader>
|
||||
<h4>{t('Configure filter scopes')}</h4>
|
||||
{showSelector && renderEditingFiltersName()}
|
||||
</ScopeHeader>
|
||||
|
||||
return (
|
||||
<ScopeContainer>
|
||||
<ScopeHeader>
|
||||
<h4>{t('Configure filter scopes')}</h4>
|
||||
{showSelector && this.renderEditingFiltersName()}
|
||||
</ScopeHeader>
|
||||
|
||||
<ScopeBody className="filter-scope-body">
|
||||
{!showSelector ? (
|
||||
<div className="warning-message">
|
||||
{t('There are no filters in this dashboard.')}
|
||||
<ScopeBody className="filter-scope-body">
|
||||
{!showSelector ? (
|
||||
<div className="warning-message">
|
||||
{t('There are no filters in this dashboard.')}
|
||||
</div>
|
||||
) : (
|
||||
<ScopeSelector className="filters-scope-selector">
|
||||
<div className={cx('filter-field-pane multi-edit-mode')}>
|
||||
{renderFilterFieldList()}
|
||||
</div>
|
||||
) : (
|
||||
<ScopeSelector className="filters-scope-selector">
|
||||
<div className={cx('filter-field-pane multi-edit-mode')}>
|
||||
{this.renderFilterFieldList()}
|
||||
</div>
|
||||
<div className="filter-scope-pane multi-edit-mode">
|
||||
{this.renderFilterScopeTree()}
|
||||
</div>
|
||||
</ScopeSelector>
|
||||
)}
|
||||
</ScopeBody>
|
||||
<div className="filter-scope-pane multi-edit-mode">
|
||||
{renderFilterScopeTree()}
|
||||
</div>
|
||||
</ScopeSelector>
|
||||
)}
|
||||
</ScopeBody>
|
||||
|
||||
<ActionsContainer>
|
||||
<Button buttonSize="small" onClick={this.onClose}>
|
||||
{t('Close')}
|
||||
<ActionsContainer>
|
||||
<Button buttonSize="small" onClick={onClose}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
{showSelector && (
|
||||
<Button buttonSize="small" buttonStyle="primary" onClick={onSave}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
{showSelector && (
|
||||
<Button
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
onClick={this.onSave}
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
</ScopeContainer>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</ActionsContainer>
|
||||
</ScopeContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -763,11 +763,11 @@ const Chart = (props: ChartProps) => {
|
||||
},
|
||||
slice.viz_type,
|
||||
)}
|
||||
queriesResponse={chart.queriesResponse ?? undefined}
|
||||
queriesResponse={chart.queriesResponse ?? null}
|
||||
timeout={timeout}
|
||||
triggerQuery={chart.triggerQuery}
|
||||
vizType={slice.viz_type}
|
||||
setControlValue={props.setControlValue}
|
||||
setControlValue={props.setControlValue ?? (() => {})}
|
||||
datasetsStatus={
|
||||
datasetsStatus as 'loading' | 'error' | 'complete' | undefined
|
||||
}
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { PureComponent } from 'react';
|
||||
import { useCallback, memo } from 'react';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
import { Draggable } from '../../dnd/DragDroppable';
|
||||
import HoverMenu from '../../menu/HoverMenu';
|
||||
import DeleteComponentButton from '../../DeleteComponentButton';
|
||||
@@ -63,50 +62,43 @@ const DividerLine = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
class Divider extends PureComponent<DividerProps> {
|
||||
constructor(props: DividerProps) {
|
||||
super(props);
|
||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
||||
}
|
||||
|
||||
handleDeleteComponent() {
|
||||
const { deleteComponent, id, parentId } = this.props;
|
||||
function Divider({
|
||||
id,
|
||||
parentId,
|
||||
component,
|
||||
depth,
|
||||
parentComponent,
|
||||
index,
|
||||
editMode,
|
||||
handleComponentDrop,
|
||||
deleteComponent,
|
||||
}: DividerProps) {
|
||||
const handleDeleteComponent = useCallback(() => {
|
||||
deleteComponent(id, parentId);
|
||||
}
|
||||
}, [deleteComponent, id, parentId]);
|
||||
|
||||
render() {
|
||||
const {
|
||||
component,
|
||||
depth,
|
||||
parentComponent,
|
||||
index,
|
||||
handleComponentDrop,
|
||||
editMode,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation="row"
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<div ref={dragSourceRef}>
|
||||
{editMode && (
|
||||
<HoverMenu position="left">
|
||||
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
<DividerLine className="dashboard-component dashboard-component-divider" />
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation="row"
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<div ref={dragSourceRef}>
|
||||
{editMode && (
|
||||
<HoverMenu position="left">
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
<DividerLine className="dashboard-component dashboard-component-divider" />
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
export default Divider;
|
||||
export default memo(Divider);
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { useState, useCallback, memo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown';
|
||||
import { EditableTitle } from '@superset-ui/core/components';
|
||||
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||
@@ -85,10 +84,6 @@ interface HeaderProps {
|
||||
updateComponents: (changes: Record<string, ComponentShape>) => void;
|
||||
}
|
||||
|
||||
interface HeaderState {
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
const HeaderStyles = styled.div`
|
||||
${({ theme }) => css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
@@ -159,149 +154,141 @@ const HeaderStyles = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
class Header extends PureComponent<HeaderProps, HeaderState> {
|
||||
handleChangeSize: (nextValue: string) => void;
|
||||
handleChangeBackground: (nextValue: string) => void;
|
||||
handleChangeText: (nextValue: string) => void;
|
||||
function Header({
|
||||
id,
|
||||
dashboardId,
|
||||
parentId,
|
||||
component,
|
||||
depth,
|
||||
parentComponent,
|
||||
index,
|
||||
editMode,
|
||||
embeddedMode,
|
||||
handleComponentDrop,
|
||||
deleteComponent,
|
||||
updateComponents,
|
||||
}: HeaderProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
constructor(props: HeaderProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isFocused: false,
|
||||
};
|
||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
||||
this.handleChangeFocus = this.handleChangeFocus.bind(this);
|
||||
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
|
||||
const handleChangeFocus = useCallback((nextFocus: boolean): void => {
|
||||
setIsFocused(nextFocus);
|
||||
}, []);
|
||||
|
||||
this.handleChangeSize = (nextValue: string) =>
|
||||
this.handleUpdateMeta('headerSize', nextValue);
|
||||
this.handleChangeBackground = (nextValue: string) =>
|
||||
this.handleUpdateMeta('background', nextValue);
|
||||
this.handleChangeText = (nextValue: string) =>
|
||||
this.handleUpdateMeta('text', nextValue);
|
||||
}
|
||||
|
||||
handleChangeFocus(nextFocus: boolean): void {
|
||||
this.setState(() => ({ isFocused: nextFocus }));
|
||||
}
|
||||
|
||||
handleUpdateMeta(metaKey: keyof ComponentMeta, nextValue: string): void {
|
||||
const { updateComponents, component } = this.props;
|
||||
if (nextValue && component.meta[metaKey] !== nextValue) {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
[metaKey]: nextValue,
|
||||
const handleUpdateMeta = useCallback(
|
||||
(metaKey: keyof ComponentMeta, nextValue: string): void => {
|
||||
if (nextValue && component.meta[metaKey] !== nextValue) {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
[metaKey]: nextValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Record<string, ComponentShape>);
|
||||
}
|
||||
}
|
||||
} as Record<string, ComponentShape>);
|
||||
}
|
||||
},
|
||||
[component, updateComponents],
|
||||
);
|
||||
|
||||
handleDeleteComponent(): void {
|
||||
const { deleteComponent, id, parentId } = this.props;
|
||||
const handleChangeSize = useCallback(
|
||||
(nextValue: string) => handleUpdateMeta('headerSize', nextValue),
|
||||
[handleUpdateMeta],
|
||||
);
|
||||
|
||||
const handleChangeBackground = useCallback(
|
||||
(nextValue: string) => handleUpdateMeta('background', nextValue),
|
||||
[handleUpdateMeta],
|
||||
);
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(nextValue: string) => handleUpdateMeta('text', nextValue),
|
||||
[handleUpdateMeta],
|
||||
);
|
||||
|
||||
const handleDeleteComponent = useCallback((): void => {
|
||||
deleteComponent(id, parentId);
|
||||
}
|
||||
}, [deleteComponent, id, parentId]);
|
||||
|
||||
render() {
|
||||
const { isFocused } = this.state;
|
||||
const headerStyle = headerStyleOptions.find(
|
||||
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
|
||||
);
|
||||
|
||||
const {
|
||||
dashboardId,
|
||||
component,
|
||||
depth,
|
||||
parentComponent,
|
||||
index,
|
||||
handleComponentDrop,
|
||||
editMode,
|
||||
embeddedMode,
|
||||
} = this.props;
|
||||
const rowStyle = backgroundStyleOptions.find(
|
||||
opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
|
||||
);
|
||||
|
||||
const headerStyle = headerStyleOptions.find(
|
||||
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
|
||||
);
|
||||
|
||||
const rowStyle = backgroundStyleOptions.find(
|
||||
opt =>
|
||||
opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
|
||||
);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation="row"
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={isFocused}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({
|
||||
dragSourceRef,
|
||||
}: {
|
||||
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
|
||||
}) => (
|
||||
<div ref={dragSourceRef}>
|
||||
{editMode &&
|
||||
depth <= 2 && ( // drag handle looks bad when nested
|
||||
<HoverMenu position="left">
|
||||
<DragHandle position="left" />
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation="row"
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={isFocused}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({
|
||||
dragSourceRef,
|
||||
}: {
|
||||
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
|
||||
}) => (
|
||||
<div ref={dragSourceRef}>
|
||||
{editMode &&
|
||||
depth <= 2 && ( // drag handle looks bad when nested
|
||||
<HoverMenu position="left">
|
||||
<DragHandle position="left" />
|
||||
</HoverMenu>
|
||||
)}
|
||||
<WithPopoverMenu
|
||||
onChangeFocus={handleChangeFocus}
|
||||
menuItems={[
|
||||
<PopoverDropdown
|
||||
id={`${component.id}-header-style`}
|
||||
options={headerStyleOptions}
|
||||
value={component.meta.headerSize as string}
|
||||
onChange={handleChangeSize}
|
||||
/>,
|
||||
<BackgroundStyleDropdown
|
||||
id={`${component.id}-background`}
|
||||
value={component.meta.background as string}
|
||||
onChange={handleChangeBackground}
|
||||
/>,
|
||||
]}
|
||||
editMode={editMode}
|
||||
>
|
||||
<HeaderStyles
|
||||
className={cx(
|
||||
'dashboard-component',
|
||||
'dashboard-component-header',
|
||||
headerStyle?.className,
|
||||
rowStyle?.className,
|
||||
)}
|
||||
>
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
<WithPopoverMenu
|
||||
onChangeFocus={this.handleChangeFocus}
|
||||
menuItems={[
|
||||
<PopoverDropdown
|
||||
id={`${component.id}-header-style`}
|
||||
options={headerStyleOptions}
|
||||
value={component.meta.headerSize as string}
|
||||
onChange={this.handleChangeSize}
|
||||
/>,
|
||||
<BackgroundStyleDropdown
|
||||
id={`${component.id}-background`}
|
||||
value={component.meta.background as string}
|
||||
onChange={this.handleChangeBackground}
|
||||
/>,
|
||||
]}
|
||||
editMode={editMode}
|
||||
>
|
||||
<HeaderStyles
|
||||
className={cx(
|
||||
'dashboard-component',
|
||||
'dashboard-component-header',
|
||||
headerStyle?.className,
|
||||
rowStyle?.className,
|
||||
)}
|
||||
>
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<DeleteComponentButton
|
||||
onDelete={this.handleDeleteComponent}
|
||||
/>
|
||||
</HoverMenu>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={component.meta.text}
|
||||
canEdit={editMode}
|
||||
onSaveTitle={this.handleChangeText}
|
||||
showTooltip={false}
|
||||
<EditableTitle
|
||||
title={component.meta.text}
|
||||
canEdit={editMode}
|
||||
onSaveTitle={handleChangeText}
|
||||
showTooltip={false}
|
||||
/>
|
||||
{!editMode && !embeddedMode && (
|
||||
<AnchorLink
|
||||
id={component.id}
|
||||
dashboardId={Number(dashboardId)}
|
||||
/>
|
||||
{!editMode && !embeddedMode && (
|
||||
<AnchorLink
|
||||
id={component.id}
|
||||
dashboardId={Number(dashboardId)}
|
||||
/>
|
||||
)}
|
||||
</HeaderStyles>
|
||||
</WithPopoverMenu>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</HeaderStyles>
|
||||
</WithPopoverMenu>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
export default memo(Header);
|
||||
|
||||
@@ -16,14 +16,15 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import cx from 'classnames';
|
||||
import type { JsonObject } from '@superset-ui/core';
|
||||
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { SafeMarkdown } from '@superset-ui/core/components';
|
||||
import { EditorHost } from 'src/core/editors';
|
||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
@@ -82,16 +83,6 @@ export interface MarkdownStateProps {
|
||||
|
||||
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
|
||||
|
||||
export interface MarkdownState {
|
||||
isFocused: boolean;
|
||||
markdownSource: string;
|
||||
editor: EditorInstance | null;
|
||||
editorMode: 'preview' | 'edit';
|
||||
undoLength: number;
|
||||
redoLength: number;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
// TODO: localize
|
||||
const MARKDOWN_PLACE_HOLDER = `# ✨Header 1
|
||||
## ✨Header 2
|
||||
@@ -140,193 +131,200 @@ interface DragChildProps {
|
||||
dragSourceRef: React.RefCallback<HTMLElement>;
|
||||
}
|
||||
|
||||
class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
|
||||
renderStartTime: number;
|
||||
function Markdown({
|
||||
id,
|
||||
parentId,
|
||||
component,
|
||||
parentComponent,
|
||||
index,
|
||||
depth,
|
||||
editMode,
|
||||
availableColumnCount,
|
||||
columnWidth,
|
||||
onResizeStart,
|
||||
onResize,
|
||||
onResizeStop,
|
||||
deleteComponent,
|
||||
handleComponentDrop,
|
||||
updateComponents,
|
||||
logEvent,
|
||||
addDangerToast,
|
||||
undoLength,
|
||||
redoLength,
|
||||
htmlSanitization,
|
||||
htmlSchemaOverrides,
|
||||
}: MarkdownProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [markdownSource, setMarkdownSource] = useState<string>(
|
||||
component.meta.code as string,
|
||||
);
|
||||
const [editor, setEditorState] = useState<EditorInstance | null>(null);
|
||||
const [editorMode, setEditorMode] = useState<'preview' | 'edit'>('preview');
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
constructor(props: MarkdownProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isFocused: false,
|
||||
markdownSource: props.component.meta.code as string,
|
||||
editor: null,
|
||||
editorMode: 'preview',
|
||||
undoLength: props.undoLength,
|
||||
redoLength: props.redoLength,
|
||||
};
|
||||
this.renderStartTime = Logger.getTimestamp();
|
||||
const renderStartTimeRef = useRef(Logger.getTimestamp());
|
||||
const prevUndoLengthRef = useRef(undoLength);
|
||||
const prevRedoLengthRef = useRef(redoLength);
|
||||
const prevComponentWidthRef = useRef(component.meta.width);
|
||||
const prevColumnWidthRef = useRef(columnWidth);
|
||||
|
||||
this.handleChangeFocus = this.handleChangeFocus.bind(this);
|
||||
this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
|
||||
this.handleMarkdownChange = this.handleMarkdownChange.bind(this);
|
||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
||||
this.handleResizeStart = this.handleResizeStart.bind(this);
|
||||
this.setEditor = this.setEditor.bind(this);
|
||||
this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.props.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
viz_type: 'markdown',
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(
|
||||
nextProps: MarkdownProps,
|
||||
state: MarkdownState,
|
||||
): MarkdownState | null {
|
||||
const { hasError, editorMode, markdownSource, undoLength, redoLength } =
|
||||
state;
|
||||
const {
|
||||
component: nextComponent,
|
||||
undoLength: nextUndoLength,
|
||||
redoLength: nextRedoLength,
|
||||
} = nextProps;
|
||||
// user click undo or redo ?
|
||||
if (nextUndoLength !== undoLength || nextRedoLength !== redoLength) {
|
||||
return {
|
||||
...state,
|
||||
undoLength: nextUndoLength,
|
||||
redoLength: nextRedoLength,
|
||||
markdownSource: nextComponent.meta.code as string,
|
||||
hasError: false,
|
||||
};
|
||||
}
|
||||
// getDerivedStateFromProps equivalent: handle undo/redo and external code changes
|
||||
useEffect(() => {
|
||||
// user click undo or redo?
|
||||
if (
|
||||
undoLength !== prevUndoLengthRef.current ||
|
||||
redoLength !== prevRedoLengthRef.current
|
||||
) {
|
||||
setMarkdownSource(component.meta.code as string);
|
||||
setHasError(false);
|
||||
prevUndoLengthRef.current = undoLength;
|
||||
prevRedoLengthRef.current = redoLength;
|
||||
} else if (
|
||||
!hasError &&
|
||||
editorMode === 'preview' &&
|
||||
nextComponent.meta.code !== markdownSource
|
||||
component.meta.code !== markdownSource
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
markdownSource: nextComponent.meta.code as string,
|
||||
};
|
||||
setMarkdownSource(component.meta.code as string);
|
||||
}
|
||||
}, [
|
||||
undoLength,
|
||||
redoLength,
|
||||
component.meta.code,
|
||||
hasError,
|
||||
editorMode,
|
||||
markdownSource,
|
||||
]);
|
||||
|
||||
return state;
|
||||
}
|
||||
// componentDidMount equivalent: log render event
|
||||
useEffect(() => {
|
||||
logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
viz_type: 'markdown',
|
||||
start_offset: renderStartTimeRef.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
});
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return {
|
||||
hasError: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: MarkdownProps): void {
|
||||
// componentDidUpdate equivalent: resize editor when width changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
this.state.editor &&
|
||||
(prevProps.component.meta.width !== this.props.component.meta.width ||
|
||||
prevProps.columnWidth !== this.props.columnWidth)
|
||||
editor &&
|
||||
(prevComponentWidthRef.current !== component.meta.width ||
|
||||
prevColumnWidthRef.current !== columnWidth)
|
||||
) {
|
||||
// Handle both Ace editor (resize method) and EditorHandle (no resize needed)
|
||||
if (typeof this.state.editor.resize === 'function') {
|
||||
this.state.editor.resize(true);
|
||||
if (typeof editor.resize === 'function') {
|
||||
editor.resize(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
prevComponentWidthRef.current = component.meta.width;
|
||||
prevColumnWidthRef.current = columnWidth;
|
||||
}, [editor, component.meta.width, columnWidth]);
|
||||
|
||||
componentDidCatch(): void {
|
||||
if (this.state.editor && this.state.editorMode === 'preview') {
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'This markdown component has an error. Please revert your recent changes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setEditor(editor: EditorInstance): void {
|
||||
// EditorHandle or Ace editor instance
|
||||
// For Ace: editor.getSession().setUseWrapMode(true)
|
||||
// For EditorHandle: wrapEnabled is handled via options
|
||||
if (editor?.getSession) {
|
||||
editor.getSession!().setUseWrapMode(true);
|
||||
}
|
||||
this.setState({
|
||||
editor,
|
||||
});
|
||||
}
|
||||
|
||||
handleChangeFocus(nextFocus: boolean | number): void {
|
||||
const nextFocused = !!nextFocus;
|
||||
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
|
||||
this.setState(() => ({ isFocused: nextFocused }));
|
||||
this.handleChangeEditorMode(nextEditMode);
|
||||
}
|
||||
|
||||
handleChangeEditorMode(mode: 'edit' | 'preview'): void {
|
||||
const nextState: MarkdownState = {
|
||||
...this.state,
|
||||
editorMode: mode,
|
||||
};
|
||||
if (mode === 'preview') {
|
||||
this.updateMarkdownContent();
|
||||
nextState.hasError = false;
|
||||
}
|
||||
|
||||
this.setState(nextState);
|
||||
}
|
||||
|
||||
updateMarkdownContent(): void {
|
||||
const { updateComponents, component } = this.props;
|
||||
if (component.meta.code !== this.state.markdownSource) {
|
||||
const updateMarkdownContent = useCallback((): void => {
|
||||
if (component.meta.code !== markdownSource) {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
code: this.state.markdownSource,
|
||||
code: markdownSource,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [component, markdownSource, updateComponents]);
|
||||
|
||||
handleMarkdownChange(nextValue: string): void {
|
||||
this.setState({
|
||||
markdownSource: nextValue,
|
||||
});
|
||||
}
|
||||
|
||||
handleDeleteComponent(): void {
|
||||
const { deleteComponent, id, parentId } = this.props;
|
||||
deleteComponent(id, parentId);
|
||||
}
|
||||
|
||||
handleResizeStart(...args: Parameters<ResizeStartCallback>): void {
|
||||
const { editorMode } = this.state;
|
||||
const { editMode, onResizeStart } = this.props;
|
||||
const isEditing = editorMode === 'edit';
|
||||
onResizeStart(...args);
|
||||
if (editMode && isEditing) {
|
||||
this.updateMarkdownContent();
|
||||
const setEditor = useCallback((editorInstance: EditorInstance): void => {
|
||||
// EditorHandle or Ace editor instance
|
||||
// For Ace: editor.getSession().setUseWrapMode(true)
|
||||
// For EditorHandle: wrapEnabled is handled via options
|
||||
if (editorInstance?.getSession) {
|
||||
editorInstance.getSession!().setUseWrapMode(true);
|
||||
}
|
||||
}
|
||||
setEditorState(editorInstance);
|
||||
}, []);
|
||||
|
||||
shouldFocusMarkdown(
|
||||
event: MouseEvent,
|
||||
container: HTMLElement | null,
|
||||
menuRef: HTMLElement | null,
|
||||
): boolean {
|
||||
if (container?.contains(event.target as Node)) return true;
|
||||
if (menuRef?.contains(event.target as Node)) return true;
|
||||
const handleChangeEditorMode = useCallback(
|
||||
(mode: 'edit' | 'preview'): void => {
|
||||
if (mode === 'preview') {
|
||||
updateMarkdownContent();
|
||||
setHasError(false);
|
||||
}
|
||||
setEditorMode(mode);
|
||||
},
|
||||
[updateMarkdownContent],
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
const handleChangeFocus = useCallback(
|
||||
(nextFocus: boolean | number): void => {
|
||||
const nextFocused = !!nextFocus;
|
||||
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
|
||||
setIsFocused(nextFocused);
|
||||
handleChangeEditorMode(nextEditMode);
|
||||
},
|
||||
[handleChangeEditorMode],
|
||||
);
|
||||
|
||||
renderEditMode(): JSX.Element {
|
||||
return (
|
||||
const handleMarkdownChange = useCallback((nextValue: string): void => {
|
||||
setMarkdownSource(nextValue);
|
||||
}, []);
|
||||
|
||||
const handleDeleteComponent = useCallback((): void => {
|
||||
deleteComponent(id, parentId);
|
||||
}, [deleteComponent, id, parentId]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(...args: Parameters<ResizeStartCallback>): void => {
|
||||
const isEditing = editorMode === 'edit';
|
||||
onResizeStart(...args);
|
||||
if (editMode && isEditing) {
|
||||
updateMarkdownContent();
|
||||
}
|
||||
},
|
||||
[editorMode, editMode, onResizeStart, updateMarkdownContent],
|
||||
);
|
||||
|
||||
const shouldFocusMarkdown = useCallback(
|
||||
(
|
||||
event: MouseEvent,
|
||||
container: HTMLElement | null,
|
||||
menuRef: HTMLElement | null,
|
||||
): boolean => {
|
||||
if (container?.contains(event.target as Node)) return true;
|
||||
if (menuRef?.contains(event.target as Node)) return true;
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRenderError = useCallback(
|
||||
(error: Error, info: { componentStack: string } | null): void => {
|
||||
setHasError(true);
|
||||
if (editorMode === 'preview') {
|
||||
addDangerToast(
|
||||
t(
|
||||
'This markdown component has an error. Please revert your recent changes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[addDangerToast, editorMode],
|
||||
);
|
||||
|
||||
const renderEditMode = useMemo(
|
||||
() => (
|
||||
<EditorHost
|
||||
id={`markdown-editor-${this.props.id}`}
|
||||
onChange={this.handleMarkdownChange}
|
||||
id={`markdown-editor-${id}`}
|
||||
onChange={handleMarkdownChange}
|
||||
width="100%"
|
||||
height="100%"
|
||||
value={
|
||||
// this allows "select all => delete" to give an empty editor
|
||||
typeof this.state.markdownSource === 'string'
|
||||
? this.state.markdownSource
|
||||
typeof markdownSource === 'string'
|
||||
? markdownSource
|
||||
: MARKDOWN_PLACE_HOLDER
|
||||
}
|
||||
language="markdown"
|
||||
@@ -336,126 +334,122 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
|
||||
onReady={(handle: EditorInstance) => {
|
||||
// The handle provides access to the underlying editor for resize
|
||||
if (handle && typeof handle.focus === 'function') {
|
||||
this.setEditor(handle);
|
||||
setEditor(handle);
|
||||
}
|
||||
}}
|
||||
data-test="editor"
|
||||
/>
|
||||
);
|
||||
}
|
||||
),
|
||||
[id, markdownSource, handleMarkdownChange, setEditor],
|
||||
);
|
||||
|
||||
renderPreviewMode(): JSX.Element {
|
||||
const { hasError } = this.state;
|
||||
|
||||
return (
|
||||
<SafeMarkdown
|
||||
source={
|
||||
hasError
|
||||
? MARKDOWN_ERROR_MESSAGE
|
||||
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER
|
||||
}
|
||||
htmlSanitization={this.props.htmlSanitization}
|
||||
htmlSchemaOverrides={this.props.htmlSchemaOverrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isFocused, editorMode } = this.state;
|
||||
|
||||
const {
|
||||
component,
|
||||
parentComponent,
|
||||
index,
|
||||
depth,
|
||||
availableColumnCount,
|
||||
columnWidth,
|
||||
onResize,
|
||||
onResizeStop,
|
||||
handleComponentDrop,
|
||||
editMode,
|
||||
} = this.props;
|
||||
|
||||
// inherit the size of parent columns
|
||||
const widthMultiple =
|
||||
parentComponent.type === COLUMN_TYPE
|
||||
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
|
||||
: component.meta.width || GRID_MIN_COLUMN_COUNT;
|
||||
|
||||
const isEditing = editorMode === 'edit';
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={isFocused}
|
||||
editMode={editMode}
|
||||
const renderPreviewMode = useMemo(
|
||||
() => (
|
||||
<ErrorBoundary
|
||||
key={hasError ? 'markdown-error' : 'markdown-ok'}
|
||||
onError={handleRenderError}
|
||||
showMessage={false}
|
||||
>
|
||||
{({ dragSourceRef }: DragChildProps) => (
|
||||
<WithPopoverMenu
|
||||
onChangeFocus={this.handleChangeFocus}
|
||||
shouldFocus={this.shouldFocusMarkdown}
|
||||
menuItems={[
|
||||
<MarkdownModeDropdown
|
||||
key={`${component.id}-mode`}
|
||||
id={`${component.id}-mode`}
|
||||
value={this.state.editorMode}
|
||||
onChange={this.handleChangeEditorMode}
|
||||
/>,
|
||||
]}
|
||||
editMode={editMode}
|
||||
<SafeMarkdown
|
||||
source={
|
||||
hasError
|
||||
? MARKDOWN_ERROR_MESSAGE
|
||||
: markdownSource || MARKDOWN_PLACE_HOLDER
|
||||
}
|
||||
htmlSanitization={htmlSanitization}
|
||||
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
[
|
||||
hasError,
|
||||
markdownSource,
|
||||
htmlSanitization,
|
||||
htmlSchemaOverrides,
|
||||
handleRenderError,
|
||||
],
|
||||
);
|
||||
|
||||
// inherit the size of parent columns
|
||||
const widthMultiple =
|
||||
parentComponent.type === COLUMN_TYPE
|
||||
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
|
||||
: component.meta.width || GRID_MIN_COLUMN_COUNT;
|
||||
|
||||
const isEditing = editorMode === 'edit';
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
<MarkdownModeDropdown
|
||||
key={`${component.id}-mode`}
|
||||
id={`${component.id}-mode`}
|
||||
value={editorMode}
|
||||
onChange={handleChangeEditorMode}
|
||||
/>,
|
||||
],
|
||||
[component.id, editorMode, handleChangeEditorMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={isFocused}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({ dragSourceRef }: DragChildProps) => (
|
||||
<WithPopoverMenu
|
||||
onChangeFocus={handleChangeFocus}
|
||||
shouldFocus={shouldFocusMarkdown}
|
||||
menuItems={menuItems}
|
||||
editMode={editMode}
|
||||
>
|
||||
<MarkdownStyles
|
||||
data-test="dashboard-markdown-editor"
|
||||
className={cx(
|
||||
'dashboard-markdown',
|
||||
isEditing && 'dashboard-markdown--editing',
|
||||
)}
|
||||
id={component.id}
|
||||
>
|
||||
<MarkdownStyles
|
||||
data-test="dashboard-markdown-editor"
|
||||
className={cx(
|
||||
'dashboard-markdown',
|
||||
isEditing && 'dashboard-markdown--editing',
|
||||
)}
|
||||
<ResizableContainer
|
||||
id={component.id}
|
||||
adjustableWidth={parentComponent.type === ROW_TYPE}
|
||||
adjustableHeight
|
||||
widthStep={columnWidth}
|
||||
widthMultiple={widthMultiple}
|
||||
heightStep={GRID_BASE_UNIT}
|
||||
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
|
||||
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
||||
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
||||
maxWidthMultiple={availableColumnCount + widthMultiple}
|
||||
onResizeStart={handleResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
editMode={isFocused ? false : editMode}
|
||||
>
|
||||
<ResizableContainer
|
||||
id={component.id}
|
||||
adjustableWidth={parentComponent.type === ROW_TYPE}
|
||||
adjustableHeight
|
||||
widthStep={columnWidth}
|
||||
widthMultiple={widthMultiple}
|
||||
heightStep={GRID_BASE_UNIT}
|
||||
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
|
||||
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
||||
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
||||
maxWidthMultiple={availableColumnCount + widthMultiple}
|
||||
onResizeStart={this.handleResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
editMode={isFocused ? false : editMode}
|
||||
<div
|
||||
ref={dragSourceRef}
|
||||
className="dashboard-component dashboard-component-chart-holder"
|
||||
data-test="dashboard-component-chart-holder"
|
||||
>
|
||||
<div
|
||||
ref={dragSourceRef}
|
||||
className="dashboard-component dashboard-component-chart-holder"
|
||||
data-test="dashboard-component-chart-holder"
|
||||
>
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<DeleteComponentButton
|
||||
onDelete={this.handleDeleteComponent}
|
||||
/>
|
||||
</HoverMenu>
|
||||
)}
|
||||
{editMode && isEditing
|
||||
? this.renderEditMode()
|
||||
: this.renderPreviewMode()}
|
||||
</div>
|
||||
</ResizableContainer>
|
||||
</MarkdownStyles>
|
||||
</WithPopoverMenu>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
{editMode && isEditing ? renderEditMode : renderPreviewMode}
|
||||
</div>
|
||||
</ResizableContainer>
|
||||
</MarkdownStyles>
|
||||
</WithPopoverMenu>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReduxState {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { memo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||
@@ -62,37 +62,37 @@ const NewComponentPlaceholder = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
export default class DraggableNewComponent extends PureComponent<DraggableNewComponentProps> {
|
||||
static defaultProps = {
|
||||
className: null,
|
||||
IconComponent: undefined,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, id, type, className, meta, IconComponent } = this.props;
|
||||
|
||||
return (
|
||||
<DragDroppable
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={0}
|
||||
depth={0}
|
||||
editMode
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<NewComponent ref={dragSourceRef} data-test="new-component">
|
||||
<NewComponentPlaceholder
|
||||
className={cx('new-component-placeholder', className)}
|
||||
>
|
||||
{IconComponent && <IconComponent iconSize="xl" />}
|
||||
</NewComponentPlaceholder>
|
||||
{label}
|
||||
</NewComponent>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
}
|
||||
function DraggableNewComponent({
|
||||
label,
|
||||
id,
|
||||
type,
|
||||
className,
|
||||
meta,
|
||||
IconComponent,
|
||||
}: DraggableNewComponentProps) {
|
||||
return (
|
||||
<DragDroppable
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={0}
|
||||
depth={0}
|
||||
editMode
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<NewComponent ref={dragSourceRef} data-test="new-component">
|
||||
<NewComponentPlaceholder
|
||||
className={cx('new-component-placeholder', className)}
|
||||
>
|
||||
{IconComponent && <IconComponent iconSize="xl" />}
|
||||
</NewComponentPlaceholder>
|
||||
{label}
|
||||
</NewComponent>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DraggableNewComponent);
|
||||
|
||||
@@ -16,11 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||
import PopoverDropdown, {
|
||||
OptionProps,
|
||||
@@ -90,18 +88,19 @@ function renderOption(option: OptionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default class BackgroundStyleDropdown extends PureComponent<BackgroundStyleDropdownProps> {
|
||||
render() {
|
||||
const { id, value, onChange } = this.props;
|
||||
return (
|
||||
<PopoverDropdown
|
||||
id={id}
|
||||
options={backgroundStyleOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
renderButton={renderButton}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function BackgroundStyleDropdown({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: BackgroundStyleDropdownProps) {
|
||||
return (
|
||||
<PopoverDropdown
|
||||
id={id}
|
||||
options={backgroundStyleOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
renderButton={renderButton}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react/no-unused-state */
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
@@ -17,15 +16,15 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { RefObject, ReactNode, PureComponent } from 'react';
|
||||
import { RefObject, ReactNode, useCallback, memo } from 'react';
|
||||
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import cx from 'classnames';
|
||||
|
||||
interface HoverMenuProps {
|
||||
position: 'left' | 'top';
|
||||
innerRef: RefObject<HTMLDivElement>;
|
||||
children: ReactNode;
|
||||
position?: 'left' | 'top';
|
||||
innerRef?: RefObject<HTMLDivElement> | null;
|
||||
children?: ReactNode;
|
||||
onHover?: (data: { isHovered: boolean }) => void;
|
||||
}
|
||||
|
||||
@@ -66,45 +65,41 @@ const HoverStyleOverrides = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export default class HoverMenu extends PureComponent<HoverMenuProps> {
|
||||
static defaultProps = {
|
||||
position: 'left',
|
||||
innerRef: null,
|
||||
children: null,
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
const { onHover } = this.props;
|
||||
function HoverMenu({
|
||||
position = 'left',
|
||||
innerRef = null,
|
||||
children = null,
|
||||
onHover,
|
||||
}: HoverMenuProps) {
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (onHover) {
|
||||
onHover({ isHovered: true });
|
||||
}
|
||||
};
|
||||
}, [onHover]);
|
||||
|
||||
handleMouseLeave = () => {
|
||||
const { onHover } = this.props;
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (onHover) {
|
||||
onHover({ isHovered: false });
|
||||
}
|
||||
};
|
||||
}, [onHover]);
|
||||
|
||||
render() {
|
||||
const { innerRef, position, children } = this.props;
|
||||
return (
|
||||
<HoverStyleOverrides className="hover-menu-container">
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={cx(
|
||||
'hover-menu',
|
||||
position === 'left' && 'hover-menu--left',
|
||||
position === 'top' && 'hover-menu--top',
|
||||
)}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
data-test="hover-menu"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</HoverStyleOverrides>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HoverStyleOverrides className="hover-menu-container">
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={cx(
|
||||
'hover-menu',
|
||||
position === 'left' && 'hover-menu--left',
|
||||
position === 'top' && 'hover-menu--top',
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-test="hover-menu"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</HoverStyleOverrides>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(HoverMenu);
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
import PopoverDropdown, {
|
||||
OnChangeHandler,
|
||||
} from '@superset-ui/core/components/PopoverDropdown';
|
||||
@@ -40,18 +38,18 @@ const dropdownOptions = [
|
||||
},
|
||||
];
|
||||
|
||||
export default class MarkdownModeDropdown extends PureComponent<MarkdownModeDropdownProps> {
|
||||
render() {
|
||||
const { id, value, onChange } = this.props;
|
||||
|
||||
return (
|
||||
<PopoverDropdown
|
||||
data-test="markdown-mode-dropdown"
|
||||
id={id}
|
||||
options={dropdownOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function MarkdownModeDropdown({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: MarkdownModeDropdownProps) {
|
||||
return (
|
||||
<PopoverDropdown
|
||||
data-test="markdown-mode-dropdown"
|
||||
id={id}
|
||||
options={dropdownOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,9 @@ test('should unfocus when another component is clicked', async () => {
|
||||
<WithPopoverMenu
|
||||
{...props}
|
||||
editMode
|
||||
shouldFocus={(event, container) => container?.contains(event.target)}
|
||||
shouldFocus={(event, container, _menuRef) =>
|
||||
container?.contains(event.target) ?? false
|
||||
}
|
||||
onChangeFocus={onChangeFocusA}
|
||||
>
|
||||
<div id="child-a" />
|
||||
@@ -117,7 +119,9 @@ test('should unfocus when another component is clicked', async () => {
|
||||
<WithPopoverMenu
|
||||
{...props}
|
||||
editMode
|
||||
shouldFocus={(event, container) => container?.contains(event.target)}
|
||||
shouldFocus={(event, container, _menuRef) =>
|
||||
container?.contains(event.target) ?? false
|
||||
}
|
||||
onChangeFocus={onChangeFocusB}
|
||||
>
|
||||
<div id="child-b" />
|
||||
|
||||
@@ -16,36 +16,49 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, CSSProperties, PureComponent } from 'react';
|
||||
import {
|
||||
ReactNode,
|
||||
CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
memo,
|
||||
} from 'react';
|
||||
import cx from 'classnames';
|
||||
import { addAlpha } from '@superset-ui/core';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
type ShouldFocusContainer = HTMLDivElement & {
|
||||
contains: (event_target: EventTarget & HTMLElement) => boolean;
|
||||
};
|
||||
|
||||
interface WithPopoverMenuProps {
|
||||
children: ReactNode;
|
||||
disableClick: boolean;
|
||||
menuItems: ReactNode[];
|
||||
onChangeFocus: (focus: boolean) => void;
|
||||
isFocused: boolean;
|
||||
// Event argument is left as "any" because of the clash. In defaultProps it seems
|
||||
children?: ReactNode;
|
||||
disableClick?: boolean;
|
||||
menuItems?: ReactNode[];
|
||||
onChangeFocus?: ((focus: boolean) => void) | null;
|
||||
isFocused?: boolean;
|
||||
// Event argument is left as "any" because of the clash. In props it seems
|
||||
// like it should be React.FocusEvent<>, however from handleClick() we can also
|
||||
// derive that type is EventListenerOrEventListenerObject.
|
||||
shouldFocus: (
|
||||
shouldFocus?: (
|
||||
event: any,
|
||||
container: ShouldFocusContainer,
|
||||
container: ShouldFocusContainer | null,
|
||||
menuRef: HTMLDivElement | null,
|
||||
) => boolean;
|
||||
editMode: boolean;
|
||||
style: CSSProperties;
|
||||
editMode?: boolean;
|
||||
style?: CSSProperties | null;
|
||||
}
|
||||
|
||||
interface WithPopoverMenuState {
|
||||
isFocused: boolean;
|
||||
}
|
||||
const defaultShouldFocus = (
|
||||
event: any,
|
||||
container: ShouldFocusContainer | null,
|
||||
menuRef: HTMLDivElement | null,
|
||||
): boolean => {
|
||||
if (container?.contains(event.target)) return true;
|
||||
if (menuRef?.contains(event.target)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const WithPopoverMenuStyles = styled.div`
|
||||
${({ theme }) => css`
|
||||
@@ -104,151 +117,103 @@ const PopoverMenuStyles = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
export default class WithPopoverMenu extends PureComponent<
|
||||
WithPopoverMenuProps,
|
||||
WithPopoverMenuState
|
||||
> {
|
||||
container: ShouldFocusContainer;
|
||||
function WithPopoverMenu({
|
||||
children = null,
|
||||
disableClick = false,
|
||||
menuItems = [],
|
||||
onChangeFocus = null,
|
||||
isFocused: isFocusedProp = false,
|
||||
shouldFocus: shouldFocusFunc = defaultShouldFocus,
|
||||
editMode = false,
|
||||
style = null,
|
||||
}: WithPopoverMenuProps) {
|
||||
const [isFocused, setIsFocused] = useState(isFocusedProp);
|
||||
const containerRef = useRef<ShouldFocusContainer | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
// Tracks the native event that just triggered focus via the container's
|
||||
// onClick so the document-level listener (registered once focused) can
|
||||
// skip it. Without this, the same click bubbles to document after a
|
||||
// re-render has detached its event.target, causing shouldFocus to return
|
||||
// false and immediately undoing the focus.
|
||||
const focusEventRef = useRef<Event | null>(null);
|
||||
|
||||
menuRef: HTMLDivElement | null;
|
||||
const handleClick = useCallback(
|
||||
(event: any) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusEvent: Event | null;
|
||||
const nativeEvent = event.nativeEvent || event;
|
||||
if (focusEventRef.current === nativeEvent) {
|
||||
focusEventRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
children: null,
|
||||
disableClick: false,
|
||||
onChangeFocus: null,
|
||||
menuItems: [],
|
||||
isFocused: false,
|
||||
shouldFocus: (
|
||||
event: any,
|
||||
container: ShouldFocusContainer,
|
||||
menuRef: HTMLDivElement | null,
|
||||
) => {
|
||||
if (container?.contains(event.target)) return true;
|
||||
if (menuRef?.contains(event.target)) return true;
|
||||
return false;
|
||||
const shouldFocusResult = shouldFocusFunc(
|
||||
event,
|
||||
containerRef.current,
|
||||
menuRef.current,
|
||||
);
|
||||
|
||||
if (shouldFocusResult === isFocused) return;
|
||||
|
||||
if (!disableClick && shouldFocusResult && !isFocused) {
|
||||
focusEventRef.current = nativeEvent;
|
||||
setIsFocused(true);
|
||||
if (onChangeFocus) onChangeFocus(true);
|
||||
} else if (!shouldFocusResult && isFocused) {
|
||||
setIsFocused(false);
|
||||
if (onChangeFocus) onChangeFocus(false);
|
||||
}
|
||||
},
|
||||
style: null,
|
||||
};
|
||||
[editMode, shouldFocusFunc, isFocused, disableClick, onChangeFocus],
|
||||
);
|
||||
|
||||
constructor(props: WithPopoverMenuProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isFocused: props.isFocused!,
|
||||
// Handle prop-driven focus changes and add/remove document listeners
|
||||
useEffect(() => {
|
||||
if (editMode && isFocusedProp && !isFocused) {
|
||||
setIsFocused(true);
|
||||
} else if (isFocused && !editMode) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}, [editMode, isFocusedProp, isFocused]);
|
||||
|
||||
// Add/remove document event listeners based on focus state
|
||||
useEffect(() => {
|
||||
if (isFocused && editMode) {
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('drag', handleClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('drag', handleClick);
|
||||
};
|
||||
this.menuRef = null;
|
||||
this.focusEvent = null;
|
||||
this.setRef = this.setRef.bind(this);
|
||||
this.setMenuRef = this.setMenuRef.bind(this);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
}, [isFocused, editMode, handleClick]);
|
||||
|
||||
componentDidUpdate(prevProps: WithPopoverMenuProps) {
|
||||
if (this.props.editMode && this.props.isFocused && !this.state.isFocused) {
|
||||
document.addEventListener('click', this.handleClick);
|
||||
document.addEventListener('drag', this.handleClick);
|
||||
this.setState({ isFocused: true });
|
||||
} else if (this.state.isFocused && !this.props.editMode) {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
document.removeEventListener('drag', this.handleClick);
|
||||
this.setState({ isFocused: false });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
document.removeEventListener('drag', this.handleClick);
|
||||
}
|
||||
|
||||
setRef(ref: ShouldFocusContainer) {
|
||||
this.container = ref;
|
||||
}
|
||||
|
||||
setMenuRef(ref: HTMLDivElement | null) {
|
||||
this.menuRef = ref;
|
||||
}
|
||||
|
||||
shouldHandleFocusChange(shouldFocus: boolean): boolean {
|
||||
const { disableClick } = this.props;
|
||||
const { isFocused } = this.state;
|
||||
|
||||
return (
|
||||
(!disableClick && shouldFocus && !isFocused) ||
|
||||
(!shouldFocus && isFocused)
|
||||
);
|
||||
}
|
||||
|
||||
handleClick(event: any) {
|
||||
if (!this.props.editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if this is the same event that just triggered focus via onClick.
|
||||
// The document-level listener registered during focus will see the same
|
||||
// event bubble up; by that time a re-render may have detached the
|
||||
// original event.target, causing shouldFocus to return false and
|
||||
// immediately undoing the focus.
|
||||
const nativeEvent = event.nativeEvent || event;
|
||||
if (this.focusEvent === nativeEvent) {
|
||||
this.focusEvent = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
onChangeFocus,
|
||||
shouldFocus: shouldFocusFunc,
|
||||
disableClick,
|
||||
} = this.props;
|
||||
|
||||
const shouldFocus = shouldFocusFunc(event, this.container, this.menuRef);
|
||||
|
||||
if (shouldFocus === this.state.isFocused) return;
|
||||
|
||||
if (!disableClick && shouldFocus && !this.state.isFocused) {
|
||||
document.addEventListener('click', this.handleClick);
|
||||
document.addEventListener('drag', this.handleClick);
|
||||
this.focusEvent = event.nativeEvent || event;
|
||||
|
||||
this.setState(() => ({ isFocused: true }));
|
||||
|
||||
if (onChangeFocus) onChangeFocus(true);
|
||||
} else if (!shouldFocus && this.state.isFocused) {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
document.removeEventListener('drag', this.handleClick);
|
||||
|
||||
this.setState(() => ({ isFocused: false }));
|
||||
|
||||
if (onChangeFocus) onChangeFocus(false);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, menuItems, editMode, style } = this.props;
|
||||
const { isFocused } = this.state;
|
||||
|
||||
return (
|
||||
<WithPopoverMenuStyles
|
||||
ref={this.setRef}
|
||||
onClick={this.handleClick}
|
||||
role="none"
|
||||
className={cx(
|
||||
'with-popover-menu',
|
||||
editMode && isFocused && 'with-popover-menu--focused',
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
|
||||
<PopoverMenuStyles ref={this.setMenuRef}>
|
||||
{menuItems.map((node: ReactNode, i: number) => (
|
||||
<div className="menu-item" key={`menu-item-${i}`}>
|
||||
{node}
|
||||
</div>
|
||||
))}
|
||||
</PopoverMenuStyles>
|
||||
)}
|
||||
</WithPopoverMenuStyles>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WithPopoverMenuStyles
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
role="none"
|
||||
className={cx(
|
||||
'with-popover-menu',
|
||||
editMode && isFocused && 'with-popover-menu--focused',
|
||||
)}
|
||||
style={style ?? undefined}
|
||||
>
|
||||
{children}
|
||||
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
|
||||
<PopoverMenuStyles ref={menuRef}>
|
||||
{menuItems.map((node: ReactNode, i: number) => (
|
||||
<div className="menu-item" key={`menu-item-${i}`}>
|
||||
{node}
|
||||
</div>
|
||||
))}
|
||||
</PopoverMenuStyles>
|
||||
)}
|
||||
</WithPopoverMenuStyles>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WithPopoverMenu);
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { NativeFilterType } from '@superset-ui/core';
|
||||
import type { Filter } from '@superset-ui/core';
|
||||
import FilterValue from './FilterValue';
|
||||
|
||||
const mockGetChartDataRequest = jest.fn();
|
||||
jest.mock('src/components/Chart/chartAction', () => ({
|
||||
getChartDataRequest: (...args: unknown[]) => mockGetChartDataRequest(...args),
|
||||
}));
|
||||
|
||||
jest.mock('src/middleware/asyncEvent', () => ({
|
||||
waitForAsyncData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => {
|
||||
const original = jest.requireActual('@superset-ui/core');
|
||||
return {
|
||||
...original,
|
||||
getChartMetadataRegistry: () => ({
|
||||
get: () => ({ enableNoResults: false }),
|
||||
}),
|
||||
SuperChart: (props: Record<string, unknown>) => (
|
||||
<div data-test="mock-super-chart" data-chart-type={props.chartType}>
|
||||
SuperChart
|
||||
</div>
|
||||
),
|
||||
isFeatureEnabled: () => false,
|
||||
getClientErrorObject: (err: unknown) =>
|
||||
Promise.resolve({
|
||||
message: 'Something went wrong',
|
||||
errors: [
|
||||
{ message: 'Test error', error_type: 'GENERIC_BACKEND_ERROR' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../useFilterOutlined', () => ({
|
||||
useFilterOutlined: () => ({
|
||||
outlinedFilterId: undefined,
|
||||
lastUpdated: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseFilterDependencies = jest.fn().mockReturnValue({});
|
||||
jest.mock('./state', () => ({
|
||||
useFilterDependencies: (...args: unknown[]) =>
|
||||
mockUseFilterDependencies(...args),
|
||||
}));
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
const createMockFilter = (overrides: Partial<Filter> = {}): Filter => ({
|
||||
id: 'NATIVE_FILTER-1',
|
||||
name: 'Test Filter',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 1, column: { name: 'country' } }],
|
||||
defaultDataMask: {},
|
||||
controlValues: {},
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
type: NativeFilterType.NativeFilter,
|
||||
description: 'Test filter description',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const getDefaultStoreState = () => ({
|
||||
dashboardInfo: { id: 1 },
|
||||
dashboardState: {
|
||||
isRefreshing: false,
|
||||
isFiltersRefreshing: false,
|
||||
directPathToChild: [],
|
||||
directPathLastUpdated: 0,
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'NATIVE_FILTER-1': createMockFilter(),
|
||||
},
|
||||
filterSets: {},
|
||||
},
|
||||
dataMask: {},
|
||||
charts: {},
|
||||
dashboardLayout: { present: {} },
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
filter: createMockFilter(),
|
||||
dataMaskSelected: {},
|
||||
onFilterSelectionChange: jest.fn(),
|
||||
inView: true,
|
||||
};
|
||||
|
||||
function renderFilterValue(
|
||||
propOverrides: Record<string, unknown> = {},
|
||||
stateOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const state = { ...getDefaultStoreState(), ...stateOverrides };
|
||||
const store = mockStore(state);
|
||||
const mergedProps = { ...defaultProps, ...propOverrides };
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<FilterValue {...(mergedProps as typeof defaultProps)} />
|
||||
</Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders loading spinner when filter has a data source', () => {
|
||||
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
renderFilterValue();
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mock-super-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders SuperChart after data loads successfully', async () => {
|
||||
mockGetChartDataRequest.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
json: { result: [{ data: [{ country: 'US' }] }] },
|
||||
});
|
||||
|
||||
renderFilterValue();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders error state when API call fails', async () => {
|
||||
mockGetChartDataRequest.mockRejectedValue(
|
||||
new Response(JSON.stringify({ message: 'Server Error' }), { status: 500 }),
|
||||
);
|
||||
|
||||
renderFilterValue();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// No ErrorMessageComponent is registered for GENERIC_BACKEND_ERROR in the
|
||||
// test environment, so FilterValue renders its fallback ErrorAlert.
|
||||
expect(await screen.findByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not fetch data when filter has not been in view', () => {
|
||||
renderFilterValue({ inView: false });
|
||||
|
||||
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not render loading spinner when filter has no data source', () => {
|
||||
const filterWithoutDataSource = createMockFilter({
|
||||
targets: [{ column: { name: 'country' } }],
|
||||
});
|
||||
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
renderFilterValue({ filter: filterWithoutDataSource });
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('skips data fetch when cascade parent filters have no values selected', () => {
|
||||
// useFilterDependencies returns dependencies with a filter (from parent defaults),
|
||||
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so
|
||||
// the component skips the fetch.
|
||||
mockUseFilterDependencies.mockReturnValue({
|
||||
filters: [{ col: 'region', op: 'IN', val: ['US'] }],
|
||||
});
|
||||
|
||||
const childFilter = createMockFilter({
|
||||
id: 'NATIVE_FILTER-CHILD',
|
||||
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
|
||||
});
|
||||
|
||||
const stateWithParent = {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'NATIVE_FILTER-CHILD': childFilter,
|
||||
'NATIVE_FILTER-PARENT': createMockFilter({
|
||||
id: 'NATIVE_FILTER-PARENT',
|
||||
}),
|
||||
},
|
||||
filterSets: {},
|
||||
},
|
||||
};
|
||||
|
||||
renderFilterValue(
|
||||
{
|
||||
filter: childFilter,
|
||||
dataMaskSelected: {},
|
||||
},
|
||||
stateWithParent,
|
||||
);
|
||||
|
||||
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
getClientErrorObject,
|
||||
isChartCustomization,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { isEqual, isEqualWith } from 'lodash';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
@@ -141,6 +142,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
clearAllTrigger,
|
||||
onClearAllComplete,
|
||||
}) => {
|
||||
const theme = useTheme() as SupersetTheme;
|
||||
const { id, targets, filterType } = filter;
|
||||
const isCustomization = isChartCustomization(filter);
|
||||
const allowedTimeGrains = isCustomization
|
||||
@@ -487,6 +489,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
enableNoResults={metadata?.enableNoResults}
|
||||
isRefreshing={isRefreshing}
|
||||
hooks={hooks}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</StyledDiv>
|
||||
|
||||
@@ -883,6 +883,14 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
|
||||
# We will return the original exception
|
||||
return exception
|
||||
|
||||
@staticmethod
|
||||
def _information_schema_ref(schema: str, catalog: str | None) -> str:
|
||||
escaped_schema = schema.replace("`", "``")
|
||||
if catalog:
|
||||
escaped_catalog = catalog.replace("`", "``")
|
||||
return f"`{escaped_catalog}.{escaped_schema}.INFORMATION_SCHEMA.TABLES`"
|
||||
return f"`{escaped_schema}.INFORMATION_SCHEMA.TABLES`"
|
||||
|
||||
@classmethod
|
||||
def get_materialized_view_names(
|
||||
cls,
|
||||
@@ -899,14 +907,8 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
|
||||
if not schema:
|
||||
return set()
|
||||
|
||||
# Construct the query to get materialized views from INFORMATION_SCHEMA
|
||||
if catalog := database.get_default_catalog():
|
||||
information_schema = f"`{catalog}.{schema}.INFORMATION_SCHEMA.TABLES`"
|
||||
else:
|
||||
information_schema = f"`{schema}.INFORMATION_SCHEMA.TABLES`"
|
||||
|
||||
# Use string formatting for the table name since it's not user input
|
||||
# The catalog and schema are from trusted sources (database configuration)
|
||||
catalog = database.get_default_catalog()
|
||||
information_schema = cls._information_schema_ref(schema, catalog)
|
||||
query = f"""
|
||||
SELECT table_name
|
||||
FROM {information_schema}
|
||||
@@ -945,15 +947,8 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
|
||||
if not schema:
|
||||
return set()
|
||||
|
||||
# Construct the query to get regular views from INFORMATION_SCHEMA
|
||||
catalog = database.get_default_catalog()
|
||||
if catalog:
|
||||
information_schema = f"`{catalog}.{schema}.INFORMATION_SCHEMA.TABLES`"
|
||||
else:
|
||||
information_schema = f"`{schema}.INFORMATION_SCHEMA.TABLES`"
|
||||
|
||||
# Use string formatting for the table name since it's not user input
|
||||
# The catalog and schema are from trusted sources (database configuration)
|
||||
information_schema = cls._information_schema_ref(schema, catalog)
|
||||
query = f"""
|
||||
SELECT table_name
|
||||
FROM {information_schema}
|
||||
|
||||
@@ -569,11 +569,11 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec):
|
||||
) -> list[str]:
|
||||
prequeries = []
|
||||
if catalog:
|
||||
catalog = f"`{catalog}`" if not catalog.startswith("`") else catalog
|
||||
prequeries.append(f"USE CATALOG {catalog}")
|
||||
escaped_catalog = catalog.replace("`", "``")
|
||||
prequeries.append(f"USE CATALOG `{escaped_catalog}`")
|
||||
if schema:
|
||||
schema = f"`{schema}`" if not schema.startswith("`") else schema
|
||||
prequeries.append(f"USE SCHEMA {schema}")
|
||||
escaped_schema = schema.replace("`", "``")
|
||||
prequeries.append(f"USE SCHEMA `{escaped_schema}`")
|
||||
return prequeries
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -162,4 +162,7 @@ class Db2EngineSpec(BaseEngineSpec):
|
||||
be anything, and we would have to block users from running any queries
|
||||
referencing tables without an explicit schema.
|
||||
"""
|
||||
return [f'set current_schema "{schema}"'] if schema else []
|
||||
if not schema:
|
||||
return []
|
||||
escaped = schema.replace('"', '""')
|
||||
return [f'set current_schema "{escaped}"']
|
||||
|
||||
@@ -268,7 +268,8 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
|
||||
schema=table.schema,
|
||||
) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f'SELECT GET_METADATA("{table.table}")')
|
||||
escaped_table = table.table.replace('"', '""')
|
||||
cursor.execute(f'SELECT GET_METADATA("{escaped_table}")')
|
||||
results = cursor.fetchone()[0]
|
||||
try:
|
||||
metadata = json.loads(results)
|
||||
|
||||
@@ -206,13 +206,24 @@ class HiveEngineSpec(PrestoEngineSpec):
|
||||
|
||||
if to_sql_kwargs["if_exists"] == "fail":
|
||||
# Ensure table doesn't already exist.
|
||||
escaped_table = (
|
||||
table.table.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
# Hive LIKE uses backslash as the escape character. Python needs \\\\
|
||||
# to produce the two-character SQL literal \\ (a single backslash).
|
||||
escape_clause = " ESCAPE '\\\\'"
|
||||
if table.schema:
|
||||
escaped_schema = table.schema.replace("`", "``")
|
||||
table_exists = not database.get_df(
|
||||
f"SHOW TABLES IN {table.schema} LIKE '{table.table}'"
|
||||
f"SHOW TABLES IN `{escaped_schema}`"
|
||||
f" LIKE '{escaped_table}'{escape_clause}"
|
||||
).empty
|
||||
else:
|
||||
table_exists = not database.get_df(
|
||||
f"SHOW TABLES LIKE '{table.table}'"
|
||||
f"SHOW TABLES LIKE '{escaped_table}'{escape_clause}"
|
||||
).empty
|
||||
|
||||
if table_exists:
|
||||
@@ -498,9 +509,12 @@ class HiveEngineSpec(PrestoEngineSpec):
|
||||
order_by: list[tuple[str, bool]] | None = None,
|
||||
filters: dict[Any, Any] | None = None,
|
||||
) -> str:
|
||||
full_table_name = (
|
||||
f"{table.schema}.{table.table}" if table.schema else table.table
|
||||
)
|
||||
escaped_table = table.table.replace("`", "``")
|
||||
if table.schema:
|
||||
escaped_schema = table.schema.replace("`", "``")
|
||||
full_table_name = f"`{escaped_schema}`.`{escaped_table}`"
|
||||
else:
|
||||
full_table_name = f"`{escaped_table}`"
|
||||
return f"SHOW PARTITIONS {full_table_name}"
|
||||
|
||||
@classmethod
|
||||
@@ -628,7 +642,8 @@ class HiveEngineSpec(PrestoEngineSpec):
|
||||
sql = "SHOW VIEWS"
|
||||
|
||||
if schema:
|
||||
sql += f" IN `{schema}`"
|
||||
escaped_schema = schema.replace("`", "``")
|
||||
sql += f" IN `{escaped_schema}`"
|
||||
|
||||
with database.get_raw_connection(schema=schema) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -694,7 +694,10 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
|
||||
be anything, and we would have to block users from running any queries
|
||||
referencing tables without an explicit schema.
|
||||
"""
|
||||
return [f'set search_path = "{schema}"'] if schema else []
|
||||
if not schema:
|
||||
return []
|
||||
escaped = schema.replace('"', '""')
|
||||
return [f'set search_path = "{escaped}"']
|
||||
|
||||
@classmethod
|
||||
def get_allow_cost_estimate(cls, extra: dict[str, Any]) -> bool:
|
||||
|
||||
@@ -413,6 +413,7 @@ class StarRocksEngineSpec(MySQLEngineSpec):
|
||||
username = database.get_effective_user(database.url_object)
|
||||
|
||||
if username:
|
||||
return [f'EXECUTE AS "{username}" WITH NO REVERT;']
|
||||
escaped = username.replace('"', '""')
|
||||
return [f'EXECUTE AS "{escaped}" WITH NO REVERT;']
|
||||
|
||||
return []
|
||||
|
||||
@@ -26,7 +26,6 @@ import logging
|
||||
import math
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from superset.commands.chart.data.get_data_command import ChartDataCommand
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
ASCIIPreview,
|
||||
ChartError,
|
||||
@@ -78,6 +77,7 @@ def generate_preview_from_form_data(
|
||||
"""
|
||||
try:
|
||||
# Execute query to get data
|
||||
from superset.commands.chart.data.get_data_command import ChartDataCommand
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.extensions import db
|
||||
|
||||
|
||||
@@ -281,6 +281,13 @@ def test_get_prequeries(mocker: MockerFixture) -> None:
|
||||
assert DatabricksNativeEngineSpec.get_prequeries(
|
||||
database, catalog="`escaped-hyphen`", schema="`hyphen-escaped`"
|
||||
) == [
|
||||
"USE CATALOG `escaped-hyphen`",
|
||||
"USE SCHEMA `hyphen-escaped`",
|
||||
"USE CATALOG ```escaped-hyphen```",
|
||||
"USE SCHEMA ```hyphen-escaped```",
|
||||
]
|
||||
|
||||
assert DatabricksNativeEngineSpec.get_prequeries(
|
||||
database, catalog="evil` USE CATALOG bad", schema="evil` USE SCHEMA bad"
|
||||
) == [
|
||||
"USE CATALOG `evil`` USE CATALOG bad`",
|
||||
"USE SCHEMA `evil`` USE SCHEMA bad`",
|
||||
]
|
||||
|
||||
@@ -81,6 +81,9 @@ def test_get_prequeries(mocker: MockerFixture) -> None:
|
||||
assert Db2EngineSpec.get_prequeries(database, schema="my_schema") == [
|
||||
'set current_schema "my_schema"'
|
||||
]
|
||||
assert Db2EngineSpec.get_prequeries(database, schema='evil"; SELECT 1--') == [
|
||||
'set current_schema "evil""; SELECT 1--"'
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -99,3 +99,87 @@ SELECT * \nFROM my_schema.my_table
|
||||
LIMIT :param_1
|
||||
""".strip()
|
||||
)
|
||||
|
||||
|
||||
def test_get_view_names_escapes_schema(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that ``get_view_names`` correctly escapes backticks in schema names
|
||||
within the SHOW VIEWS statement.
|
||||
"""
|
||||
from superset.db_engine_specs.hive import HiveEngineSpec
|
||||
|
||||
database = mocker.MagicMock()
|
||||
inspector = mocker.MagicMock()
|
||||
|
||||
conn = mocker.MagicMock()
|
||||
cursor = mocker.MagicMock()
|
||||
cursor.fetchall.return_value = []
|
||||
conn.__enter__ = mocker.MagicMock(return_value=conn)
|
||||
conn.__exit__ = mocker.MagicMock(return_value=False)
|
||||
conn.cursor.return_value = cursor
|
||||
database.get_raw_connection.return_value = conn
|
||||
|
||||
HiveEngineSpec.get_view_names(database, inspector, schema="evil` UNION SELECT 1--")
|
||||
cursor.execute.assert_called_once()
|
||||
sql = cursor.execute.call_args[0][0]
|
||||
assert "IN `evil`` UNION SELECT 1--`" in sql
|
||||
|
||||
|
||||
def test_df_to_sql_escapes_like_wildcards(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test that ``df_to_sql`` escapes ``%`` and ``_`` wildcard characters in the
|
||||
SHOW TABLES LIKE pattern used to detect table existence.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
from superset.db_engine_specs.hive import HiveEngineSpec
|
||||
from superset.exceptions import SupersetException
|
||||
from superset.sql.parse import Table
|
||||
|
||||
database = mocker.MagicMock()
|
||||
# Simulate an existing table so df_to_sql raises before reaching the upload path
|
||||
database.get_df.return_value = pd.DataFrame({"name": ["sales_%_2024"]})
|
||||
|
||||
with pytest.raises(SupersetException, match="Table already exists"):
|
||||
HiveEngineSpec.df_to_sql(
|
||||
database=database,
|
||||
table=Table("sales_%_2024", "my_schema"),
|
||||
df=pd.DataFrame({"a": [1]}),
|
||||
to_sql_kwargs={"if_exists": "fail"},
|
||||
)
|
||||
|
||||
database.get_df.assert_called_once()
|
||||
sql = database.get_df.call_args[0][0]
|
||||
assert r"\%" in sql
|
||||
assert r"\_" in sql
|
||||
assert "ESCAPE" in sql
|
||||
|
||||
|
||||
def test_partition_query_escapes_identifiers() -> None:
|
||||
"""
|
||||
Test that ``_partition_query`` correctly backtick-quotes table and schema names
|
||||
in the SHOW PARTITIONS statement.
|
||||
"""
|
||||
from superset.db_engine_specs.hive import HiveEngineSpec
|
||||
from superset.sql.parse import Table
|
||||
|
||||
result = HiveEngineSpec._partition_query(
|
||||
table=Table("my_table", "my_schema"),
|
||||
indexes=[],
|
||||
database=None, # type: ignore
|
||||
)
|
||||
assert result == "SHOW PARTITIONS `my_schema`.`my_table`"
|
||||
|
||||
result = HiveEngineSpec._partition_query(
|
||||
table=Table("evil`tbl", "evil`schema"),
|
||||
indexes=[],
|
||||
database=None, # type: ignore
|
||||
)
|
||||
assert result == "SHOW PARTITIONS `evil``schema`.`evil``tbl`"
|
||||
|
||||
result = HiveEngineSpec._partition_query(
|
||||
table=Table("no_schema_tbl"),
|
||||
indexes=[],
|
||||
database=None, # type: ignore
|
||||
)
|
||||
assert result == "SHOW PARTITIONS `no_schema_tbl`"
|
||||
|
||||
@@ -147,6 +147,9 @@ def test_get_prequeries(mocker: MockerFixture) -> None:
|
||||
|
||||
assert spec.get_prequeries(database) == []
|
||||
assert spec.get_prequeries(database, schema="test") == ['set search_path = "test"']
|
||||
assert spec.get_prequeries(database, schema='evil"; SELECT 1--') == [
|
||||
'set search_path = "evil""; SELECT 1--"'
|
||||
]
|
||||
|
||||
|
||||
def test_get_default_schema_for_query(mocker: MockerFixture) -> None:
|
||||
|
||||
@@ -169,6 +169,11 @@ def test_impersonation_username(mocker: MockerFixture) -> None:
|
||||
'EXECUTE AS "alice" WITH NO REVERT;'
|
||||
]
|
||||
|
||||
database.get_effective_user.return_value = 'evil" WITH NO REVERT; DROP TABLE x--'
|
||||
assert StarRocksEngineSpec.get_prequeries(database) == [
|
||||
'EXECUTE AS "evil"" WITH NO REVERT; DROP TABLE x--" WITH NO REVERT;'
|
||||
]
|
||||
|
||||
|
||||
def test_impersonation_disabled(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
|
||||
@@ -19,6 +19,47 @@
|
||||
Tests for preview_utils query context column building.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from superset.mcp_service.chart import preview_utils
|
||||
|
||||
|
||||
def _imports_chart_data_command(node: ast.Import | ast.ImportFrom) -> bool:
|
||||
blocked_module = "superset.commands.chart.data.get_data_command"
|
||||
|
||||
if isinstance(node, ast.Import):
|
||||
return any(
|
||||
alias.name == blocked_module or alias.name.startswith(f"{blocked_module}.")
|
||||
for alias in node.names
|
||||
)
|
||||
|
||||
module = node.module or ""
|
||||
return (
|
||||
module == blocked_module
|
||||
or module.startswith(f"{blocked_module}.")
|
||||
or (
|
||||
module == "superset.commands.chart.data"
|
||||
and any(alias.name == "get_data_command" for alias in node.names)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_preview_utils_does_not_top_level_import_chart_data_command():
|
||||
"""preview_utils constants should stay safe to import before app setup."""
|
||||
source_path = inspect.getsourcefile(preview_utils) or preview_utils.__file__
|
||||
source = Path(source_path).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
top_level_imports = [
|
||||
node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||
]
|
||||
|
||||
assert preview_utils.SUPPORTED_FORM_DATA_PREVIEW_FORMATS == frozenset(
|
||||
{"ascii", "table", "vega_lite"}
|
||||
)
|
||||
assert not any(_imports_chart_data_command(node) for node in top_level_imports)
|
||||
|
||||
|
||||
class TestPreviewUtilsColumnBuilding:
|
||||
"""Tests for x_axis + groupby column building in generate_preview_from_form_data.
|
||||
|
||||
@@ -1007,12 +1007,12 @@ class TestChartDataCommandValidation:
|
||||
mock_dataset = MagicMock()
|
||||
mock_dataset.id = 10
|
||||
|
||||
# ChartDataCommand is module-level import in preview_utils;
|
||||
# db and QueryContextFactory are local imports inside the function.
|
||||
# ChartDataCommand, db, and QueryContextFactory are local imports inside
|
||||
# the function so preview_utils stays safe to import before app setup.
|
||||
with (
|
||||
patch("superset.extensions.db") as mock_db,
|
||||
patch(
|
||||
"superset.mcp_service.chart.preview_utils.ChartDataCommand",
|
||||
"superset.commands.chart.data.get_data_command.ChartDataCommand",
|
||||
return_value=mock_command,
|
||||
),
|
||||
patch(
|
||||
@@ -1061,7 +1061,7 @@ class TestChartDataCommandValidation:
|
||||
with (
|
||||
patch("superset.extensions.db") as mock_db,
|
||||
patch(
|
||||
"superset.mcp_service.chart.preview_utils.ChartDataCommand",
|
||||
"superset.commands.chart.data.get_data_command.ChartDataCommand",
|
||||
return_value=mock_command,
|
||||
),
|
||||
patch(
|
||||
|
||||
Reference in New Issue
Block a user