mirror of
https://github.com/apache/superset.git
synced 2026-06-17 05:29:21 +00:00
Compare commits
25 Commits
fix/report
...
enxdev/cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d88a6730cd | ||
|
|
ec623f3b93 | ||
|
|
6d08e79259 | ||
|
|
395bbb9611 | ||
|
|
01ed81785e | ||
|
|
7b4efacbc2 | ||
|
|
7cb4990403 | ||
|
|
c90b2571d7 | ||
|
|
1a4941eee5 | ||
|
|
d839cca995 | ||
|
|
0ec7e7df99 | ||
|
|
9d8287e1bd | ||
|
|
0c696cea7e | ||
|
|
fe625a917e | ||
|
|
a69f9eb00d | ||
|
|
1311d040ba | ||
|
|
5c1609e3f9 | ||
|
|
715c07b5c7 | ||
|
|
a1eba0f9a1 | ||
|
|
568337f370 | ||
|
|
f170dc1d9e | ||
|
|
09c09f3f6b | ||
|
|
c65c9523aa | ||
|
|
94e0071883 | ||
|
|
380e70060b |
@@ -101,7 +101,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
|
||||
@@ -4936,7 +4936,7 @@
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.60.1", "@typescript-eslint/parser@^8.60.1":
|
||||
"@typescript-eslint/parser@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289"
|
||||
integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==
|
||||
@@ -4947,6 +4947,17 @@
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/parser@^8.61.0":
|
||||
version "8.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.0.tgz#1afe73c9ccce16b7a26d6b95f9400b0ccc34af87"
|
||||
integrity sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.61.0"
|
||||
"@typescript-eslint/types" "8.61.0"
|
||||
"@typescript-eslint/typescript-estree" "8.61.0"
|
||||
"@typescript-eslint/visitor-keys" "8.61.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b"
|
||||
@@ -4956,6 +4967,15 @@
|
||||
"@typescript-eslint/types" "^8.60.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.61.0":
|
||||
version "8.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.0.tgz#417a2feac32e8ebd336d63f068c3b42b736ea1ac"
|
||||
integrity sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.61.0"
|
||||
"@typescript-eslint/types" "^8.61.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8"
|
||||
@@ -4964,16 +4984,29 @@
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.61.0":
|
||||
version "8.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz#93c2520d05653fe65eb9ee98efc74fd0134a7852"
|
||||
integrity sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.61.0"
|
||||
"@typescript-eslint/visitor-keys" "8.61.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
|
||||
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@^8.60.1":
|
||||
"@typescript-eslint/tsconfig-utils@8.61.0", "@typescript-eslint/tsconfig-utils@^8.60.1":
|
||||
version "8.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz#05d6e3ff20001674ebcd22d03dac29ee448043ba"
|
||||
integrity sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@^8.61.0":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz#ca88080e0cf191d49516d7f300b67aa090d2254f"
|
||||
integrity sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379"
|
||||
@@ -4990,11 +5023,16 @@
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
|
||||
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
|
||||
|
||||
"@typescript-eslint/types@^8.60.1":
|
||||
"@typescript-eslint/types@8.61.0":
|
||||
version "8.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d"
|
||||
integrity sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==
|
||||
|
||||
"@typescript-eslint/types@^8.60.1", "@typescript-eslint/types@^8.61.0":
|
||||
version "8.61.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.1.tgz#0c51f518e4e6848371a1c988e859d59eb7522d5a"
|
||||
integrity sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74"
|
||||
@@ -5010,6 +5048,21 @@
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.61.0":
|
||||
version "8.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz#98ca47260bbf627fc28f018b3a0abf00e3090690"
|
||||
integrity sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.61.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.61.0"
|
||||
"@typescript-eslint/types" "8.61.0"
|
||||
"@typescript-eslint/visitor-keys" "8.61.0"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b"
|
||||
@@ -5028,6 +5081,14 @@
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.61.0":
|
||||
version "8.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz#39b4e1ab8936d23bea973d39fd092f9aa21f275e"
|
||||
integrity sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.61.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz"
|
||||
|
||||
@@ -144,7 +144,7 @@ dynamodb = ["pydynamodb>=0.4.2"]
|
||||
solr = ["sqlalchemy-solr >= 0.2.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
|
||||
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
|
||||
excel = ["xlrd>=1.2.0, <1.3"]
|
||||
excel = ["xlrd>=2.0.2, <2.1"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.2.4,<4.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
@@ -156,7 +156,7 @@ firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=26.4.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
|
||||
hana = ["hdbcli==2.28.21", "sqlalchemy_hana==0.4.0"]
|
||||
hive = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
@@ -173,7 +173,7 @@ motherduck = ["apache-superset[duckdb]"]
|
||||
mysql = ["mysqlclient>=2.1.0, <3"]
|
||||
ocient = [
|
||||
"sqlalchemy-ocient>=1.0.0",
|
||||
"pyocient>=1.0.15, <2",
|
||||
"pyocient>=1.0.15, <4",
|
||||
"shapely",
|
||||
"geojson",
|
||||
]
|
||||
|
||||
214
superset-frontend/package-lock.json
generated
214
superset-frontend/package-lock.json
generated
@@ -102,7 +102,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"google-auth-library": "^10.7.0",
|
||||
"immer": "^11.1.8",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -261,7 +261,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.68.0",
|
||||
"oxlint": "^1.69.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
@@ -8324,9 +8324,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.68.0.tgz",
|
||||
"integrity": "sha512-wEdsIspexXLLMCPAEOcCuFLMt6aE3AzTuA/nQKLPRnoJ+EQTturmGheDkhHuuVHx0GbutjQ3JKmEn+Gz6Ag28Q==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.69.0.tgz",
|
||||
"integrity": "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8341,9 +8341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm64": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.68.0.tgz",
|
||||
"integrity": "sha512-6aZRNNXQTsYtgaus8HTb9nuCcsrQTlKXGnktwvwW0n/SooRWNxNb3925grDkC63aEYZuCIyOVLV16IdYIoC2aQ==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.69.0.tgz",
|
||||
"integrity": "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8358,9 +8358,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.68.0.tgz",
|
||||
"integrity": "sha512-lVTbsE3kO4bLpZELgjRZuAJc8kP98wb83yMXWH8gaPaFZ+cM2IDeZto4ByoUAYj0Mxv2rvw+A1ssZequSepVSg==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.69.0.tgz",
|
||||
"integrity": "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8375,9 +8375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-x64": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.68.0.tgz",
|
||||
"integrity": "sha512-nCmw2XrmQskjBUh/sfP5yKs93V68LijQgjd1cuuZ/q4SCARngLYs60/qqyzuMsg8QQ9KArDI98hxs/RDGE4KRQ==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.69.0.tgz",
|
||||
"integrity": "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8392,9 +8392,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.68.0.tgz",
|
||||
"integrity": "sha512-TI4ovQJliYE9V6e06cEv+qEI9uj7Ao65fmif4er4HD+aouyYyh0P31q2jh3KtqsOHHcQqv2PZ61TjJFLpBDGWQ==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.69.0.tgz",
|
||||
"integrity": "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8409,9 +8409,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.68.0.tgz",
|
||||
"integrity": "sha512-LcNnEi9g71Cmry5ZpLbKT+oVv+/zYG3hYVAbBBB5X85nOQZSk8l92CnDkxJMcxUg0NCnMCOFZuaVDlMyv4tYJw==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.69.0.tgz",
|
||||
"integrity": "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8426,9 +8426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.68.0.tgz",
|
||||
"integrity": "sha512-OovHahL3FX4UaK+hgSf11llUx2vszqjSdQQ61Ck9InOEI/ptZoC4XSQJurITqItVvd53JSlmkLMeaNjM1PoQew==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.69.0.tgz",
|
||||
"integrity": "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8443,9 +8443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.68.0.tgz",
|
||||
"integrity": "sha512-YbzTglnHLzzi9zv5or8Ztz5fykAoZE8W9iM42/bOrF4HBSB6rJTqdLQWuoP76EHQw9DuKl76K1QmFlG29sPJXQ==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.69.0.tgz",
|
||||
"integrity": "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8460,9 +8460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.68.0.tgz",
|
||||
"integrity": "sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.69.0.tgz",
|
||||
"integrity": "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8477,9 +8477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.68.0.tgz",
|
||||
"integrity": "sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.69.0.tgz",
|
||||
"integrity": "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -8494,9 +8494,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.68.0.tgz",
|
||||
"integrity": "sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.69.0.tgz",
|
||||
"integrity": "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -8511,9 +8511,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.68.0.tgz",
|
||||
"integrity": "sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.69.0.tgz",
|
||||
"integrity": "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -8528,9 +8528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.68.0.tgz",
|
||||
"integrity": "sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.69.0.tgz",
|
||||
"integrity": "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -8545,9 +8545,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.68.0.tgz",
|
||||
"integrity": "sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.69.0.tgz",
|
||||
"integrity": "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8562,9 +8562,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.68.0.tgz",
|
||||
"integrity": "sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.69.0.tgz",
|
||||
"integrity": "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8579,9 +8579,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.68.0.tgz",
|
||||
"integrity": "sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.69.0.tgz",
|
||||
"integrity": "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8596,9 +8596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.68.0.tgz",
|
||||
"integrity": "sha512-fHNtVqPHSYE7UFDSLVFUjxQjnSVXxseNJmRW+XuP4pXXDwePdPda43NL7/BBCFTxHjycOc44JNDaOPtFDNui9A==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.69.0.tgz",
|
||||
"integrity": "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8613,9 +8613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.68.0.tgz",
|
||||
"integrity": "sha512-NnKXr4Wgo4nps3erhrE0f8shBvBPZMHg72nDsvX0JyrRvsNiP3f1JNvbCKh+A6VFvpF7ZoJxu904P3cKMhvZnA==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.69.0.tgz",
|
||||
"integrity": "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -8630,9 +8630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.68.0.tgz",
|
||||
"integrity": "sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.69.0.tgz",
|
||||
"integrity": "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -21856,9 +21856,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.6.2",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
|
||||
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
|
||||
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
@@ -26108,6 +26108,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -32587,9 +32602,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.68.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.68.0.tgz",
|
||||
"integrity": "sha512-dXcbq+xsmLrMy6T8d0euf3IYUfLmjHIE11pOxiUSi5LHkFZaYPv568R6sEjcavVpUxoaQe66UBuK4HEi74NxpA==",
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.69.0.tgz",
|
||||
"integrity": "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -32602,25 +32617,25 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint/binding-android-arm-eabi": "1.68.0",
|
||||
"@oxlint/binding-android-arm64": "1.68.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.68.0",
|
||||
"@oxlint/binding-darwin-x64": "1.68.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.68.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.68.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.68.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.68.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.68.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.68.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.68.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.68.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.68.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.68.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.68.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.68.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.68.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.68.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.68.0"
|
||||
"@oxlint/binding-android-arm-eabi": "1.69.0",
|
||||
"@oxlint/binding-android-arm64": "1.69.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.69.0",
|
||||
"@oxlint/binding-darwin-x64": "1.69.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.69.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.69.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.69.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.69.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.69.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.69.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.69.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.69.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.69.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.69.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.69.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.69.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.69.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.69.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.69.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint-tsgolint": ">=0.22.1",
|
||||
@@ -43220,6 +43235,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url/node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
@@ -44462,7 +44492,7 @@
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.8",
|
||||
"dompurify": "^3.4.9",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
@@ -44583,9 +44613,9 @@
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
|
||||
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -44946,7 +44976,7 @@
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-tip": "^0.9.1",
|
||||
"dompurify": "^3.4.8",
|
||||
"dompurify": "^3.4.9",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
@@ -44962,9 +44992,9 @@
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
|
||||
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"google-auth-library": "^10.7.0",
|
||||
"immer": "^11.1.8",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -344,7 +344,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.68.0",
|
||||
"oxlint": "^1.69.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
"types": "./lib/authentication/index.d.ts",
|
||||
"default": "./lib/authentication/index.js"
|
||||
},
|
||||
"./chat": {
|
||||
"types": "./lib/chat/index.d.ts",
|
||||
"default": "./lib/chat/index.js"
|
||||
},
|
||||
"./navigation": {
|
||||
"types": "./lib/navigation/index.d.ts",
|
||||
"default": "./lib/navigation/index.js"
|
||||
},
|
||||
"./commands": {
|
||||
"types": "./lib/commands/index.d.ts",
|
||||
"default": "./lib/commands/index.js"
|
||||
|
||||
184
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
184
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Chat contribution API for Superset extensions.
|
||||
*
|
||||
* Chat is a dedicated contribution type (not a view): an extension registers
|
||||
* a chat via {@link registerChat} and the host owns where and how it is
|
||||
* mounted. The host applies singleton resolution — multiple chat extensions
|
||||
* may register, but exactly one is active at a time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { chat } from '@apache-superset/core';
|
||||
*
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* () => <AcmeTrigger />,
|
||||
* () => <AcmePanel />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import type { Disposable, Event } from '../common';
|
||||
|
||||
export interface Chat {
|
||||
/** The unique identifier for the chat. */
|
||||
id: string;
|
||||
/** The display name of the chat. */
|
||||
name: string;
|
||||
/** Optional description of the chat, for display in contribution manifests. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type DisplayMode = 'floating' | 'panel';
|
||||
|
||||
/**
|
||||
* Registers a chat provider. The host applies singleton resolution — only one
|
||||
* chat is active at a time: the most recently registered chat wins, and
|
||||
* disposing it restores the previously registered one. Re-registering an id
|
||||
* replaces that registration in place.
|
||||
*
|
||||
* When a registration with a different id takes over the active slot (or the
|
||||
* active chat is disposed), the host closes the panel first, firing
|
||||
* {@link onDidClose}; an in-place same-id replacement keeps the open state.
|
||||
*
|
||||
* Disposing the returned Disposable unregisters the chat.
|
||||
*
|
||||
* @param chat The chat descriptor (id, name).
|
||||
* @param trigger A function returning the collapsed bubble element. Owned by
|
||||
* the extension — dynamic state such as unread counts and badges lives here.
|
||||
* Hidden by the host when in panel mode.
|
||||
* @param panel A function returning the chat panel element. Mounted by the
|
||||
* host as a floating overlay in 'floating' mode, or docked at the side of
|
||||
* the viewport in 'panel' mode (the reference host docks a fixed-width
|
||||
* overlay at the right edge; hosts may integrate a true layout slot
|
||||
* instead). Same component in both modes.
|
||||
* @returns A Disposable that unregisters the chat when disposed.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* () => <AcmeTrigger />,
|
||||
* () => <AcmePanel />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerChat(
|
||||
chat: Chat,
|
||||
trigger: () => ReactElement,
|
||||
panel: () => ReactElement,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Returns the active chat descriptor.
|
||||
*
|
||||
* @returns A copy of the active Chat descriptor, or undefined if none is
|
||||
* registered. Mutating the returned object has no effect on the registry.
|
||||
*/
|
||||
export declare function getChat(): Chat | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is registered.
|
||||
*/
|
||||
export declare const onDidRegisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Opens the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when no chat is registered or the panel is already open.
|
||||
*/
|
||||
export declare function open(): void;
|
||||
|
||||
/**
|
||||
* Closes the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when the panel is not open.
|
||||
*/
|
||||
export declare function close(): void;
|
||||
|
||||
/**
|
||||
* Returns whether the active chat's panel is currently open.
|
||||
*
|
||||
* @returns True if the chat panel is open.
|
||||
*/
|
||||
export declare function isOpen(): boolean;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel opens. Also fired by the host's own
|
||||
* controls, not only by an extension's open() call.
|
||||
*/
|
||||
export declare const onDidOpen: Event<void>;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel closes. Also fired when the host closes the
|
||||
* panel itself, e.g. because the active chat was disposed or displaced by a
|
||||
* different chat.
|
||||
*/
|
||||
export declare const onDidClose: Event<void>;
|
||||
|
||||
/**
|
||||
* Returns the current display mode.
|
||||
*
|
||||
* @returns The current DisplayMode.
|
||||
*/
|
||||
export declare function getDisplayMode(): DisplayMode;
|
||||
|
||||
/**
|
||||
* Sets the display mode.
|
||||
*
|
||||
* The mode is host-global and applies to whichever chat is active, regardless
|
||||
* of which extension calls it. Hosts may also change the mode through their
|
||||
* own controls — use onDidChangeDisplayMode to observe all changes rather than
|
||||
* assuming the last setDisplayMode() call won.
|
||||
*
|
||||
* @param displayMode The display mode to switch to.
|
||||
*/
|
||||
export declare function setDisplayMode(displayMode: DisplayMode): void;
|
||||
|
||||
/**
|
||||
* Event fired when the display mode changes, whether triggered by an
|
||||
* extension via setDisplayMode() or by host-provided controls.
|
||||
*/
|
||||
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
|
||||
|
||||
/**
|
||||
* Event fired when the panel is resized in panel mode.
|
||||
*
|
||||
* The host owns the resizer handle and drag interaction; a host without a
|
||||
* resizer never fires this event. (The reference host mounts the panel at a
|
||||
* fixed width and does not provide a resizer, so subscribers receive no
|
||||
* events there.) Listen to this event to adapt internal layout to the
|
||||
* available width; do not rely on it firing.
|
||||
*/
|
||||
export declare const onDidResizePanel: Event<{ width: number }>;
|
||||
|
||||
// TODO: client actions API — tool availability functions will be added here
|
||||
// once the client_actions SIP is finalized. The chat namespace is the
|
||||
// intended integration point between the two SIPs.
|
||||
@@ -223,8 +223,6 @@ export interface Extension {
|
||||
dependencies: string[];
|
||||
/** Human-readable description of the extension */
|
||||
description: string;
|
||||
/** List of other extensions that this extension depends on */
|
||||
extensionDependencies: string[];
|
||||
/** Unique identifier for the extension */
|
||||
id: string;
|
||||
/** Human-readable name of the extension */
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
* This module defines the aggregate interfaces used by the extension.json
|
||||
* manifest and the `superset-extensions` build command. Individual metadata
|
||||
* types are defined in their respective namespace modules (commands, views,
|
||||
* menus, editors) and re-exported here for the manifest schema.
|
||||
* menus, editors, chat) and re-exported here for the manifest schema.
|
||||
*/
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
@@ -71,7 +72,8 @@ export interface MenuContributions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
* Aggregates all contributions (commands, menus, views, editors, and chat)
|
||||
* provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
@@ -82,4 +84,10 @@ export interface Contributions {
|
||||
views: ViewContributions;
|
||||
/** List of editors. */
|
||||
editors?: Editor[];
|
||||
/**
|
||||
* The chat contributed by the extension — at most one per extension, since
|
||||
* the host applies singleton resolution and renders exactly one active
|
||||
* chat at a time.
|
||||
*/
|
||||
chat?: Chat;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
*/
|
||||
export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as chat from './chat';
|
||||
export * as commands from './commands';
|
||||
export * as editors from './editors';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
export * as navigation from './navigation';
|
||||
export * as sqlLab from './sqlLab';
|
||||
export * as views from './views';
|
||||
export * as contributions from './contributions';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Navigation namespace for Superset extensions.
|
||||
*
|
||||
* Exposes the current application surface so extensions can react to route
|
||||
* changes without polling. Entity-level context (chart, dashboard, dataset)
|
||||
* is intentionally not included here — surface-specific namespaces that
|
||||
* resolve entity payloads are introduced in later phases.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* The set of top-level application surfaces.
|
||||
*
|
||||
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
|
||||
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
|
||||
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
|
||||
* single entity is active. `'sqllab'` is the SQL editor where
|
||||
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
|
||||
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
|
||||
* the welcome surface and the fallback for any route not explicitly enumerated.
|
||||
*/
|
||||
export type Page =
|
||||
| 'dashboard'
|
||||
| 'dashboard_list'
|
||||
| 'explore'
|
||||
| 'chart_list'
|
||||
| 'sqllab'
|
||||
| 'query_history'
|
||||
| 'saved_queries'
|
||||
| 'dataset'
|
||||
| 'dataset_list'
|
||||
| 'home';
|
||||
|
||||
/**
|
||||
* Returns the current page surface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const page = navigation.getPage();
|
||||
* if (page === 'dashboard') {
|
||||
* // react to being on a dashboard surface
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getPage(): Page;
|
||||
|
||||
/**
|
||||
* Event fired whenever the user navigates to a different surface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = navigation.onDidChangePage(page => {
|
||||
* if (page === 'dashboard') {
|
||||
* // react to navigating onto a dashboard surface
|
||||
* }
|
||||
* });
|
||||
* // later:
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangePage: Event<Page>;
|
||||
@@ -43,7 +43,7 @@
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.8",
|
||||
"dompurify": "^3.4.9",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.4.8",
|
||||
"dompurify": "^3.4.9",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import { css, styled } from '@apache-superset/core/theme';
|
||||
export default styled.div`
|
||||
${({ theme }) => css`
|
||||
/* Base table styles */
|
||||
padding: ${theme.sizeUnit * 5}px;
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
|
||||
@@ -1613,8 +1613,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
pageSize={pageSize}
|
||||
serverPaginationData={serverPaginationData}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
width={widthFromState}
|
||||
height={heightFromState}
|
||||
width={Math.max(0, widthFromState - theme.sizeUnit * 10)}
|
||||
height={Math.max(0, heightFromState - theme.sizeUnit * 10)}
|
||||
serverPagination={serverPagination}
|
||||
onServerPaginationChange={handleServerPaginationChange}
|
||||
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
|
||||
import type {
|
||||
ControlPanelSectionConfig,
|
||||
CustomControlItem,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { isCustomControlItem } from '@superset-ui/chart-controls';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { render } from '@testing-library/react';
|
||||
import { SqlaFormData } from '@superset-ui/core';
|
||||
@@ -28,6 +32,7 @@ import DeckGLGeoJson, {
|
||||
computeGeoJsonIconOptionsFromJsOutput,
|
||||
computeGeoJsonIconOptionsFromFormData,
|
||||
getPoints,
|
||||
getLayer,
|
||||
} from './Geojson';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
@@ -295,3 +300,158 @@ test('DeckGLGeoJson falls back to legacy map_style when provider-specific style
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const baseFormData: SqlaFormData = {
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'deck_geojson',
|
||||
slice_id: 1,
|
||||
fill_color_picker: { r: 0, g: 0, b: 255, a: 1 },
|
||||
stroke_color_picker: { r: 0, g: 0, b: 0, a: 1 },
|
||||
};
|
||||
|
||||
const baseLayerArgs = {
|
||||
onContextMenu: jest.fn(),
|
||||
filterState: undefined,
|
||||
setDataMask: jest.fn(),
|
||||
payload: { data: { type: 'FeatureCollection', features: [] } },
|
||||
setTooltip: jest.fn(),
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('getLayer preserves rendering for existing charts without new point radius fields', () => {
|
||||
// Simulate form data from an existing chart that only has point_radius_scale
|
||||
const legacyFormData = {
|
||||
...baseFormData,
|
||||
point_radius_scale: 200,
|
||||
// point_radius and point_radius_units intentionally absent
|
||||
};
|
||||
|
||||
const layer = getLayer({ formData: legacyFormData, ...baseLayerArgs });
|
||||
const { props } = layer;
|
||||
|
||||
// Should match deck.gl defaults, NOT the new control panel defaults
|
||||
expect(props.getPointRadius).toBe(1); // deck.gl default, not 10
|
||||
expect(props.pointRadiusUnits).toBe('meters'); // deck.gl default, not 'pixels'
|
||||
expect(props.pointRadiusScale).toBe(200); // user's saved value preserved
|
||||
});
|
||||
|
||||
test('getLayer uses control panel defaults for new charts', () => {
|
||||
const newChartFormData = {
|
||||
...baseFormData,
|
||||
point_radius: 10,
|
||||
point_radius_units: 'pixels',
|
||||
point_radius_scale: 1,
|
||||
};
|
||||
|
||||
const layer = getLayer({ formData: newChartFormData, ...baseLayerArgs });
|
||||
const { props } = layer;
|
||||
|
||||
expect(props.getPointRadius).toBe(10);
|
||||
expect(props.pointRadiusUnits).toBe('pixels');
|
||||
expect(props.pointRadiusScale).toBe(1);
|
||||
});
|
||||
|
||||
test('getLayer falls back to defaults when legacy fields are null', () => {
|
||||
// The old point_radius_scale control had `default: null`, so legacy charts
|
||||
// can have null persisted; it must fall back to 1, not coerce to 0.
|
||||
const nullFormData = {
|
||||
...baseFormData,
|
||||
point_radius: null,
|
||||
point_radius_scale: null,
|
||||
};
|
||||
|
||||
const layer = getLayer({ formData: nullFormData, ...baseLayerArgs });
|
||||
const { props } = layer;
|
||||
|
||||
expect(props.getPointRadius).toBe(1);
|
||||
expect(props.pointRadiusScale).toBe(1);
|
||||
});
|
||||
|
||||
test('getLayer preserves an explicit zero radius scale', () => {
|
||||
const zeroFormData = {
|
||||
...baseFormData,
|
||||
point_radius_scale: 0,
|
||||
};
|
||||
|
||||
const layer = getLayer({ formData: zeroFormData, ...baseLayerArgs });
|
||||
const { props } = layer;
|
||||
|
||||
expect(props.pointRadiusScale).toBe(0);
|
||||
});
|
||||
|
||||
test('getLayer coerces free-form string radius values to numbers', () => {
|
||||
// Free-form SelectControls can store user-typed values as strings
|
||||
const stringFormData = {
|
||||
...baseFormData,
|
||||
point_radius: '3',
|
||||
point_radius_scale: '0.25',
|
||||
};
|
||||
|
||||
const layer = getLayer({ formData: stringFormData, ...baseLayerArgs });
|
||||
const { props } = layer;
|
||||
|
||||
expect(props.getPointRadius).toBe(3);
|
||||
expect(props.pointRadiusScale).toBe(0.25);
|
||||
});
|
||||
|
||||
type ControlConfig = {
|
||||
default?: unknown;
|
||||
validators?: unknown[];
|
||||
choices?: [unknown, unknown][];
|
||||
renderTrigger?: boolean;
|
||||
};
|
||||
|
||||
const controlItems = controlPanel.controlPanelSections
|
||||
.filter(
|
||||
(s: ControlPanelSectionConfig | null): s is ControlPanelSectionConfig =>
|
||||
s !== null,
|
||||
)
|
||||
.flatMap((section: ControlPanelSectionConfig) => section.controlSetRows)
|
||||
.flat();
|
||||
|
||||
const findControlConfig = (name: string): ControlConfig | undefined =>
|
||||
(controlItems.filter(isCustomControlItem) as CustomControlItem[]).find(
|
||||
(item: CustomControlItem) => item.name === name,
|
||||
)?.config as ControlConfig | undefined;
|
||||
|
||||
test('controlPanel exposes a Point Radius control defaulting to 10', () => {
|
||||
const config = findControlConfig('point_radius');
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.default).toBe(10);
|
||||
expect(config?.renderTrigger).toBe(true);
|
||||
expect(config?.validators).toHaveLength(1);
|
||||
expect(config?.choices).toEqual(
|
||||
expect.arrayContaining([
|
||||
[1, '1'],
|
||||
[10, '10'],
|
||||
[100, '100'],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('controlPanel Point Radius Scale defaults to 1 with fractional choices', () => {
|
||||
const config = findControlConfig('point_radius_scale');
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.default).toBe(1);
|
||||
expect(config?.renderTrigger).toBe(true);
|
||||
expect(config?.validators).toHaveLength(1);
|
||||
expect(config?.choices).toEqual(
|
||||
expect.arrayContaining([
|
||||
[0.1, '0.1'],
|
||||
[1, '1'],
|
||||
[10, '10'],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('controlPanel Point Radius Units defaults to pixels', () => {
|
||||
const config = findControlConfig('point_radius_units');
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.default).toBe('pixels');
|
||||
expect(config?.renderTrigger).toBe(true);
|
||||
expect(config?.choices?.map(([value]) => value)).toEqual([
|
||||
'pixels',
|
||||
'meters',
|
||||
'common',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -254,6 +254,15 @@ export const computeGeoJsonIconOptionsFromFormData = (
|
||||
iconSizeUnits: fd.icon_size_unit,
|
||||
});
|
||||
|
||||
// Free-form SelectControls can yield string values, and legacy charts may have
|
||||
// null persisted for these fields, so coerce to a number (falling back to the
|
||||
// provided default for null/undefined/NaN input, while preserving an explicit 0)
|
||||
// before handing them to deck.gl's numeric layer props.
|
||||
const toNumber = (value: unknown, fallback: number) => {
|
||||
const num = Number(value ?? fallback);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
};
|
||||
|
||||
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
|
||||
formData,
|
||||
onContextMenu,
|
||||
@@ -328,7 +337,11 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
|
||||
getFillColor(feature, filterState?.value),
|
||||
getLineColor,
|
||||
getLineWidth: fd.line_width || 1,
|
||||
pointRadiusScale: fd.point_radius_scale,
|
||||
// Use deck.gl defaults as fallbacks for backward compatibility with existing charts.
|
||||
// New charts will get control panel defaults (point_radius=10, units='pixels', scale=1).
|
||||
getPointRadius: toNumber(fd.point_radius, 1),
|
||||
pointRadiusUnits: fd.point_radius_units ?? 'meters',
|
||||
pointRadiusScale: toNumber(fd.point_radius_scale, 1),
|
||||
lineWidthUnits: fd.line_width_unit,
|
||||
pointType,
|
||||
...labelOpts,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
legacyValidateInteger,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
validateNumber,
|
||||
validateInteger,
|
||||
} from '@superset-ui/core';
|
||||
import { formatSelectOptions } from '../../utilities/utils';
|
||||
import {
|
||||
@@ -352,15 +354,56 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'point_radius',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
label: t('Point Radius'),
|
||||
description: t(
|
||||
'The radius of point features, in the units specified below. ' +
|
||||
'The final rendered size is this value multiplied by Point Radius Scale.',
|
||||
),
|
||||
validators: [validateInteger],
|
||||
default: 10,
|
||||
choices: formatSelectOptions([1, 5, 10, 20, 50, 100]),
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'point_radius_scale',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
label: t('Point Radius Scale'),
|
||||
validators: [legacyValidateInteger],
|
||||
default: null,
|
||||
choices: formatSelectOptions([0, 100, 200, 300, 500]),
|
||||
description: t(
|
||||
'A multiplier applied to the point radius. ' +
|
||||
'Use this to uniformly scale all points.',
|
||||
),
|
||||
validators: [validateNumber],
|
||||
default: 1,
|
||||
choices: formatSelectOptions([0.1, 0.5, 1, 2, 5, 10]),
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'point_radius_units',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Point Radius Units'),
|
||||
description: t(
|
||||
'The unit for point radius. Use "pixels" for consistent ' +
|
||||
'screen-space sizing regardless of zoom level.',
|
||||
),
|
||||
default: 'pixels',
|
||||
choices: [
|
||||
['pixels', t('Pixels')],
|
||||
['meters', t('Meters')],
|
||||
['common', t('Common (unit per pixel at zoom 0)')],
|
||||
],
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
287
superset-frontend/src/components/ChatMount/ChatMount.test.tsx
Normal file
287
superset-frontend/src/components/ChatMount/ChatMount.test.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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 { act, render, screen } from 'spec/helpers/testing-library';
|
||||
import { chat } from 'src/core/chat';
|
||||
import ChatMount from '.';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
// Reset host-owned state shared across tests in this module.
|
||||
chat.close();
|
||||
chat.setDisplayMode('floating');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders nothing when no chat extension is registered', () => {
|
||||
render(<ChatMount />);
|
||||
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the trigger bubble of the registered chat', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
// The panel stays unmounted until the chat is opened.
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('mounts the panel when the chat opens and unmounts it on close', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
act(() => chat.open());
|
||||
|
||||
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||
// In floating mode the trigger stays mounted alongside the open panel.
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
|
||||
act(() => chat.close());
|
||||
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the last-registered chat when several are installed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <div>First Bubble</div>,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
// Last-loaded wins: the second registration takes over the singleton slot.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('reacts to a chat registering after the initial render', () => {
|
||||
render(<ChatMount />);
|
||||
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a takeover mounts the incoming chat closed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <div>First Bubble</div>,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
act(() => chat.open());
|
||||
expect(screen.getByText('First Panel')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// The displaced chat's open state must not leak into the winner.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('panel mode docks the open panel and hides the trigger', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
act(() => {
|
||||
chat.setDisplayMode('panel');
|
||||
chat.open();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => chat.close());
|
||||
|
||||
// A closed chat in panel mode renders nothing — the trigger is hidden too.
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a crashing panel does not take the trigger down with it', () => {
|
||||
const FailingPanel = () => {
|
||||
throw new Error('panel blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <FailingPanel />,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
act(() => chat.open());
|
||||
|
||||
// The panel's boundary contains the crash; the trigger keeps rendering so
|
||||
// the user is not stranded without a way back.
|
||||
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing trigger so it does not crash the host', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('chat blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatMount />)).not.toThrow();
|
||||
// The mount slot still renders (the boundary lives inside it), confirming
|
||||
// the provider was actually exercised and contained.
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a chat whose provider function itself throws', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => {
|
||||
throw new Error('provider blew up');
|
||||
},
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
// ChatRenderer wraps provider() in a component so ErrorBoundary catches
|
||||
// synchronous throws from the provider function, not just from its output.
|
||||
expect(() => render(<ChatMount />)).not.toThrow();
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recovers from a crashed chat when a different chat takes over', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('first chat blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// The boundary is keyed per registration, so the latched crash from the
|
||||
// first chat does not blank the second one.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recovers when a crashed chat re-registers a fixed version under the same id', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('broken release');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <div>Fixed Bubble</div>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Same id, new registrationId: the remounted boundary renders the fix.
|
||||
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
|
||||
});
|
||||
149
superset-frontend/src/components/ChatMount/index.tsx
Normal file
149
superset-frontend/src/components/ChatMount/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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 { type ReactElement, useRef, useSyncExternalStore } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import { getChatSnapshot, subscribeToChatState } from 'src/core/chat';
|
||||
|
||||
const CHAT_EDGE_MARGIN = 24;
|
||||
const PANEL_MODE_WIDTH = 400;
|
||||
|
||||
/**
|
||||
* Wraps a chat provider in a React component so that ErrorBoundary can catch
|
||||
* synchronous throws from the provider function itself. Calling `provider()`
|
||||
* inline (e.g. `{activeChat.panel()}`) would throw outside React's render
|
||||
* boundary and crash the host.
|
||||
*/
|
||||
const ChatRenderer = ({ provider }: { provider: () => ReactElement }) =>
|
||||
provider();
|
||||
|
||||
const ChatMount = () => {
|
||||
const theme = useTheme();
|
||||
// Notify at most once per registration; a crash can re-render and would
|
||||
// otherwise re-toast, while a replacement (new registrationId) deserves a
|
||||
// fresh notification if it crashes too.
|
||||
const crashNotifiedFor = useRef<number | null>(null);
|
||||
|
||||
// The active chat, the open state, and the display mode are read from one
|
||||
// immutable registry snapshot so a render never mixes state from two
|
||||
// different store versions (the tearing useSyncExternalStore prevents).
|
||||
const {
|
||||
open: panelOpen,
|
||||
mode,
|
||||
active,
|
||||
} = useSyncExternalStore(subscribeToChatState, getChatSnapshot);
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { registrationId } = active;
|
||||
|
||||
const onProviderError = (error: Error) => {
|
||||
// Fault isolation: contain the crash, log it, surface a one-time
|
||||
// notification, and leave the slot empty rather than parking a
|
||||
// persistent error card.
|
||||
logging.error('[chat] provider crashed', error);
|
||||
if (crashNotifiedFor.current !== registrationId) {
|
||||
crashNotifiedFor.current = registrationId;
|
||||
store.dispatch(addDangerToast(t('The chat failed to load.')));
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'panel') {
|
||||
// Panel mode hides the trigger and docks the panel to the right edge.
|
||||
// Interim approximation of the "layout slot between header and footer"
|
||||
// from the chat API contract — the dock overlays the page until the host
|
||||
// grows a real layout slot and resizer chrome.
|
||||
if (!panelOpen) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-test="chat-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: ${PANEL_MODE_WIDTH}px;
|
||||
background: ${theme.colorBgContainer};
|
||||
box-shadow: ${theme.boxShadow};
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary
|
||||
key={registrationId}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.panel} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chat-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHAT_EDGE_MARGIN}px;
|
||||
bottom: ${CHAT_EDGE_MARGIN}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
{/*
|
||||
Each provider gets its own boundary so a crashing panel cannot take
|
||||
the trigger down with it (the trigger is the user's only way back).
|
||||
Keyed by registrationId: Superset's ErrorBoundary latches its error
|
||||
state, so a takeover, fallback, or same-id re-registration must
|
||||
remount the boundary to recover.
|
||||
*/}
|
||||
{panelOpen && (
|
||||
<ErrorBoundary
|
||||
key={`panel-${registrationId}`}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.panel} />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary
|
||||
key={`trigger-${registrationId}`}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.trigger} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMount;
|
||||
@@ -295,6 +295,7 @@ export interface ListViewProps<T extends object = any> {
|
||||
name: ReactNode;
|
||||
onSelect: (rows: any[]) => any;
|
||||
type?: 'primary' | 'secondary' | 'danger';
|
||||
hidden?: (rows: any[]) => boolean;
|
||||
}>;
|
||||
bulkSelectEnabled?: boolean;
|
||||
disableBulkSelect?: () => void;
|
||||
@@ -509,7 +510,16 @@ export function ListView<T extends object = any>({
|
||||
{t('Deselect all')}
|
||||
</span>
|
||||
<div className="divider" />
|
||||
{bulkActions.map(action => (
|
||||
{bulkActions
|
||||
.filter(
|
||||
action =>
|
||||
!action.hidden?.(
|
||||
selectedFlatRows.map(
|
||||
(r: any) => r.original,
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(action => (
|
||||
<Button
|
||||
data-test="bulk-select-action"
|
||||
data-test-action-key={action.key}
|
||||
|
||||
327
superset-frontend/src/core/chat/index.test.ts
Normal file
327
superset-frontend/src/core/chat/index.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 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 { createElement } from 'react';
|
||||
import { chat, getActiveChat, getChatSnapshot } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
const trigger = () => createElement('button', null, 'Bubble');
|
||||
const panel = () => createElement('div', null, 'Panel');
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
// Reset host-owned state shared across tests in this module.
|
||||
chat.close();
|
||||
chat.setDisplayMode('floating');
|
||||
});
|
||||
|
||||
test('getChat returns undefined when no chat is registered', () => {
|
||||
expect(chat.getChat()).toBeUndefined();
|
||||
expect(getActiveChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('registerChat resolves the registered chat with its providers', () => {
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
|
||||
disposables.push(chat.registerChat(descriptor, trigger, panel));
|
||||
|
||||
expect(chat.getChat()).toEqual(descriptor);
|
||||
expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel });
|
||||
});
|
||||
|
||||
test('getChat returns a copy that cannot mutate the registry', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel),
|
||||
);
|
||||
|
||||
const copy = chat.getChat();
|
||||
copy!.name = 'Hijacked';
|
||||
|
||||
expect(chat.getChat()?.name).toBe('Acme Chat');
|
||||
});
|
||||
|
||||
test('the last-registered chat wins when multiple are installed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.id).toBe('second.chat');
|
||||
});
|
||||
|
||||
test('disposing the active chat falls back to the previous registration', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
);
|
||||
const second = chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.id).toBe('second.chat');
|
||||
|
||||
second.dispose();
|
||||
|
||||
expect(chat.getChat()?.id).toBe('first.chat');
|
||||
});
|
||||
|
||||
test('re-registering an id replaces the previous registration', () => {
|
||||
const stalePanel = () => createElement('div', null, 'Stale');
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel),
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.name).toBe('Acme v2');
|
||||
expect(getActiveChat()?.panel).toBe(panel);
|
||||
});
|
||||
|
||||
test('each registration gets a distinct registrationId, including same-id replacements', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
const first = getActiveChat()?.registrationId;
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
const second = getActiveChat()?.registrationId;
|
||||
|
||||
expect(first).toBeDefined();
|
||||
expect(second).toBeDefined();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test('disposing a registration twice unregisters only once', () => {
|
||||
const unregistered = jest.fn();
|
||||
disposables.push(chat.onDidUnregisterChat(unregistered));
|
||||
|
||||
const registration = chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
registration.dispose();
|
||||
registration.dispose();
|
||||
|
||||
expect(unregistered).toHaveBeenCalledTimes(1);
|
||||
expect(chat.getChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
|
||||
const registered = jest.fn();
|
||||
const unregistered = jest.fn();
|
||||
disposables.push(
|
||||
chat.onDidRegisterChat(registered),
|
||||
chat.onDidUnregisterChat(unregistered),
|
||||
);
|
||||
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme' };
|
||||
const registration = chat.registerChat(descriptor, trigger, panel);
|
||||
|
||||
expect(registered).toHaveBeenCalledWith(descriptor);
|
||||
expect(unregistered).not.toHaveBeenCalled();
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(unregistered).toHaveBeenCalledWith(descriptor);
|
||||
});
|
||||
|
||||
test('a disposed event subscription stops receiving notifications', () => {
|
||||
const registered = jest.fn();
|
||||
const subscription = chat.onDidRegisterChat(registered);
|
||||
subscription.dispose();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(registered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('open and close toggle the panel and fire once', () => {
|
||||
const opened = jest.fn();
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed));
|
||||
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme' };
|
||||
disposables.push(chat.registerChat(descriptor, trigger, panel));
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
|
||||
chat.open();
|
||||
// Opening an already-open panel is a no-op and must not re-fire.
|
||||
chat.open();
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
|
||||
chat.close();
|
||||
chat.close();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('open is a no-op while no chat is registered', () => {
|
||||
const opened = jest.fn();
|
||||
disposables.push(chat.onDidOpen(opened));
|
||||
|
||||
chat.open();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(opened).not.toHaveBeenCalled();
|
||||
|
||||
// A registration arriving later therefore starts closed.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('a takeover by a different id closes the displaced chat panel', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
const first = { id: 'first.chat', name: 'First' };
|
||||
disposables.push(chat.registerChat(first, trigger, panel));
|
||||
chat.open();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
|
||||
// The incoming chat must not mount into an open state it never requested.
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a same-id replacement keeps the open state', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
// Upgrade in place: same id, new providers.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disposing the active chat while open closes it; the fallback starts closed', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
);
|
||||
const second = { id: 'second.chat', name: 'Second' };
|
||||
const registration = chat.registerChat(second, trigger, panel);
|
||||
chat.open();
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(chat.getChat()?.id).toBe('first.chat');
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('disposing an inactive registration leaves the open state untouched', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
const inactive = chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
inactive.dispose();
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disposing the last chat while open resets the open state', () => {
|
||||
const registration = chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
chat.open();
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
|
||||
// A registration arriving much later must not inherit a stale open state.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel),
|
||||
);
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('mode defaults to floating and setDisplayMode fires only on change', () => {
|
||||
const modeChanged = jest.fn();
|
||||
disposables.push(chat.onDidChangeDisplayMode(modeChanged));
|
||||
|
||||
expect(chat.getDisplayMode()).toBe('floating');
|
||||
|
||||
// Setting the current mode is a no-op.
|
||||
chat.setDisplayMode('floating');
|
||||
expect(modeChanged).not.toHaveBeenCalled();
|
||||
|
||||
chat.setDisplayMode('panel');
|
||||
expect(chat.getDisplayMode()).toBe('panel');
|
||||
expect(modeChanged).toHaveBeenCalledWith('panel');
|
||||
});
|
||||
|
||||
test('the snapshot is immutable per version and consistent with the registry', () => {
|
||||
const before = getChatSnapshot();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
const after = getChatSnapshot();
|
||||
// Unchanged references for old snapshots; a new object per change.
|
||||
expect(after).not.toBe(before);
|
||||
expect(before.active).toBeUndefined();
|
||||
expect(after).toMatchObject({
|
||||
open: true,
|
||||
mode: 'floating',
|
||||
active: getActiveChat(),
|
||||
});
|
||||
expect(after.version).toBeGreaterThan(before.version);
|
||||
// Stable reference between changes.
|
||||
expect(getChatSnapshot()).toBe(after);
|
||||
});
|
||||
240
superset-frontend/src/core/chat/index.ts
Normal file
240
superset-frontend/src/core/chat/index.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Host implementation of the `chat` contribution type.
|
||||
*
|
||||
* Chat is a dedicated contribution type, not a view: extensions register via
|
||||
* the public `chat.registerChat()` and the host owns mounting, open/close
|
||||
* state, and the display mode. Multiple chat extensions may register, but the
|
||||
* host applies singleton resolution — the most-recently-registered chat is
|
||||
* active; disposing it falls back to the previous one.
|
||||
*
|
||||
* Open-state policy across active-chat transitions: when the active chat's
|
||||
* identity changes — a takeover by a different id, disposal falling back to a
|
||||
* different id, or disposal of the last chat — the panel is closed (firing
|
||||
* `onDidClose`) so the incoming chat never mounts into an open state it did
|
||||
* not request. A same-id re-registration is an upgrade in place and keeps the
|
||||
* open state.
|
||||
*
|
||||
* The public namespace (`chat`) is exposed to extensions on
|
||||
* `window.superset`; the other exports are host-internal accessors for
|
||||
* ChatMount and are NOT part of the public `@apache-superset/core` API.
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import type { chat as chatApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createValueEventEmitter, createEventEmitter } from '../utils';
|
||||
|
||||
type Chat = chatApi.Chat;
|
||||
type DisplayMode = chatApi.DisplayMode;
|
||||
|
||||
/** A registered chat: its descriptor plus the host-mountable providers. */
|
||||
export interface RegisteredChat {
|
||||
/** The chat descriptor passed to `registerChat`. */
|
||||
chat: Chat;
|
||||
/** Renders the collapsed bubble. Hidden by the host in panel mode. */
|
||||
trigger: () => ReactElement;
|
||||
/** Renders the chat panel, mounted per the current {@link DisplayMode}. */
|
||||
panel: () => ReactElement;
|
||||
/**
|
||||
* Unique per registration (a same-id re-registration gets a new one). The
|
||||
* host UI keys mounts and fault containment on it, so a replacement resets
|
||||
* crashed error boundaries instead of inheriting their latched state.
|
||||
*/
|
||||
registrationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable snapshot of the whole chat state, rebuilt on every change.
|
||||
* Returned by reference from `getChatSnapshot` so `useSyncExternalStore`
|
||||
* consumers read registrations, open state, and mode from one consistent
|
||||
* object instead of tearing across separate live reads.
|
||||
*/
|
||||
export interface ChatSnapshot {
|
||||
/** Monotonic change counter, useful as a memo/effect dependency. */
|
||||
version: number;
|
||||
/** Whether the active chat's panel is open. */
|
||||
open: boolean;
|
||||
/** The current display mode. */
|
||||
mode: DisplayMode;
|
||||
/** The active registration, or undefined when none is registered. */
|
||||
active: RegisteredChat | undefined;
|
||||
}
|
||||
|
||||
/** Registration order is the singleton-resolution order: last entry wins. */
|
||||
const registrations: RegisteredChat[] = [];
|
||||
|
||||
let panelOpen = false;
|
||||
let nextRegistrationId = 1;
|
||||
|
||||
const registerEmitter = createEventEmitter<Chat>();
|
||||
const unregisterEmitter = createEventEmitter<Chat>();
|
||||
const openEmitter = createEventEmitter<void>();
|
||||
const closeEmitter = createEventEmitter<void>();
|
||||
const resizePanelEmitter = createEventEmitter<{ width: number }>();
|
||||
const modeEmitter = createValueEventEmitter<DisplayMode>('floating');
|
||||
|
||||
/**
|
||||
* Host-internal: resolves the active chat with its providers.
|
||||
* The most-recently-registered chat wins; when it is disposed the previous
|
||||
* registration takes over the slot again.
|
||||
*/
|
||||
export const getActiveChat = (): RegisteredChat | undefined =>
|
||||
registrations[registrations.length - 1];
|
||||
|
||||
let snapshot: ChatSnapshot = {
|
||||
version: 0,
|
||||
open: false,
|
||||
mode: modeEmitter.getCurrent(),
|
||||
active: undefined,
|
||||
};
|
||||
|
||||
const stateSubscribers = new Set<() => void>();
|
||||
|
||||
const notifyState = () => {
|
||||
snapshot = {
|
||||
version: snapshot.version + 1,
|
||||
open: panelOpen,
|
||||
mode: modeEmitter.getCurrent(),
|
||||
active: getActiveChat(),
|
||||
};
|
||||
stateSubscribers.forEach(fn => fn());
|
||||
};
|
||||
|
||||
export const subscribeToChatState = (listener: () => void): (() => void) => {
|
||||
stateSubscribers.add(listener);
|
||||
return () => {
|
||||
stateSubscribers.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const getChatSnapshot = (): ChatSnapshot => snapshot;
|
||||
|
||||
/** Closes the panel and fires `onDidClose`. */
|
||||
const closePanel = () => {
|
||||
panelOpen = false;
|
||||
closeEmitter.fire();
|
||||
};
|
||||
|
||||
const registerChat: typeof chatApi.registerChat = (
|
||||
chat: Chat,
|
||||
trigger: () => ReactElement,
|
||||
panel: () => ReactElement,
|
||||
): Disposable => {
|
||||
const previousActive = getActiveChat();
|
||||
|
||||
// Re-registering an id replaces the previous entry and moves it to the
|
||||
// most-recent position, mirroring the view registry's same-id semantics.
|
||||
const existingIndex = registrations.findIndex(r => r.chat.id === chat.id);
|
||||
if (existingIndex !== -1) {
|
||||
registrations.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
const entry: RegisteredChat = {
|
||||
chat,
|
||||
trigger,
|
||||
panel,
|
||||
registrationId: nextRegistrationId,
|
||||
};
|
||||
nextRegistrationId += 1;
|
||||
registrations.push(entry);
|
||||
registerEmitter.fire(chat);
|
||||
|
||||
// A takeover by a different id closes the displaced chat's panel so the
|
||||
// incoming chat never mounts already-open; a same-id replacement is an
|
||||
// upgrade in place and keeps the open state.
|
||||
if (panelOpen && previousActive && previousActive.chat.id !== chat.id) {
|
||||
closePanel();
|
||||
}
|
||||
notifyState();
|
||||
|
||||
return new Disposable(() => {
|
||||
const index = registrations.indexOf(entry);
|
||||
if (index === -1) {
|
||||
// Already removed — replaced by a same-id registration or disposed twice.
|
||||
return;
|
||||
}
|
||||
const wasActive = getActiveChat() === entry;
|
||||
registrations.splice(index, 1);
|
||||
unregisterEmitter.fire(chat);
|
||||
// Disposing the active chat closes its panel; the fallback chat (if any)
|
||||
// starts closed. Disposing an inactive registration leaves the open
|
||||
// state of the active chat untouched.
|
||||
if (panelOpen && wasActive) {
|
||||
closePanel();
|
||||
}
|
||||
notifyState();
|
||||
});
|
||||
};
|
||||
|
||||
const getChat: typeof chatApi.getChat = (): Chat | undefined => {
|
||||
const active = getActiveChat();
|
||||
// Copy so extensions cannot mutate another extension's descriptor.
|
||||
return active ? { ...active.chat } : undefined;
|
||||
};
|
||||
|
||||
const open: typeof chatApi.open = (): void => {
|
||||
const active = getActiveChat();
|
||||
// Open state only exists while a chat is registered; opening an empty slot
|
||||
// would otherwise leak `open` into a future, unrelated registration.
|
||||
if (panelOpen || !active) return;
|
||||
panelOpen = true;
|
||||
openEmitter.fire();
|
||||
notifyState();
|
||||
};
|
||||
|
||||
const close: typeof chatApi.close = (): void => {
|
||||
const active = getActiveChat();
|
||||
if (!panelOpen || !active) return;
|
||||
closePanel();
|
||||
notifyState();
|
||||
};
|
||||
|
||||
const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen;
|
||||
|
||||
const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode =>
|
||||
modeEmitter.getCurrent();
|
||||
|
||||
const setDisplayMode: typeof chatApi.setDisplayMode = (
|
||||
displayMode: DisplayMode,
|
||||
): void => {
|
||||
if (displayMode === modeEmitter.getCurrent()) return;
|
||||
modeEmitter.fire(displayMode);
|
||||
notifyState();
|
||||
};
|
||||
|
||||
export const chat: typeof chatApi = {
|
||||
registerChat,
|
||||
getChat,
|
||||
onDidRegisterChat: registerEmitter.subscribe,
|
||||
onDidUnregisterChat: unregisterEmitter.subscribe,
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
onDidOpen: openEmitter.subscribe,
|
||||
onDidClose: closeEmitter.subscribe,
|
||||
getDisplayMode,
|
||||
setDisplayMode,
|
||||
onDidChangeDisplayMode: modeEmitter.subscribe,
|
||||
// The host fires this from its panel resizer; until that chrome exists the
|
||||
// event is exposed but never fires.
|
||||
onDidResizePanel: resizePanelEmitter.subscribe,
|
||||
};
|
||||
@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
|
||||
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
|
||||
});
|
||||
|
||||
test('handles errors in event listeners gracefully', () => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
const errorListener = jest.fn(() => {
|
||||
throw new Error('Listener error');
|
||||
});
|
||||
const successListener = jest.fn();
|
||||
|
||||
manager.onDidRegister(errorListener);
|
||||
manager.onDidRegister(successListener);
|
||||
|
||||
manager.registerProvider(createMockEditor(), createMockEditorComponent());
|
||||
|
||||
// Both listeners should have been called
|
||||
expect(errorListener).toHaveBeenCalledTimes(1);
|
||||
expect(successListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Error should have been logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error in event listener:',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('reset clears all providers and language mappings', () => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type EditorLanguage = editors.EditorLanguage;
|
||||
type EditorProvider = editors.EditorProvider;
|
||||
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
|
||||
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
|
||||
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
|
||||
|
||||
/**
|
||||
* Listener function type for events.
|
||||
*/
|
||||
type Listener<T> = (e: T) => void;
|
||||
|
||||
/**
|
||||
* Simple event emitter for editor provider lifecycle events.
|
||||
*/
|
||||
class EventEmitter<T> {
|
||||
private listeners: Set<Listener<T>> = new Set();
|
||||
|
||||
/**
|
||||
* Subscribe to this event.
|
||||
* @param listener The listener function to call when the event is fired.
|
||||
* @returns A Disposable to unsubscribe from the event.
|
||||
*/
|
||||
subscribe(listener: Listener<T>): Disposable {
|
||||
this.listeners.add(listener);
|
||||
return new Disposable(() => {
|
||||
this.listeners.delete(listener);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the event with the given data.
|
||||
* @param data The event data to pass to listeners.
|
||||
*/
|
||||
fire(data: T): void {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(data);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error in event listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton manager for editor providers.
|
||||
* Handles registration, resolution, and lifecycle of custom editor implementations.
|
||||
@@ -83,15 +47,9 @@ class EditorProviders {
|
||||
*/
|
||||
private languageToProvider: Map<EditorLanguage, string> = new Map();
|
||||
|
||||
/**
|
||||
* Event emitter for provider registration events.
|
||||
*/
|
||||
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
|
||||
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
|
||||
|
||||
/**
|
||||
* Event emitter for provider unregistration events.
|
||||
*/
|
||||
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
|
||||
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
|
||||
|
||||
private syncListeners: Set<() => void> = new Set();
|
||||
|
||||
|
||||
@@ -27,11 +27,13 @@ export const core: typeof coreType = {
|
||||
};
|
||||
|
||||
export * from './authentication';
|
||||
export * from './chat';
|
||||
export * from './commands';
|
||||
export * from './editors';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
export * from './sqlLab';
|
||||
export * from './utils';
|
||||
export * from './views';
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { menus as menusApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type MenuItem = menusApi.MenuItem;
|
||||
type Menu = menusApi.Menu;
|
||||
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
|
||||
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
|
||||
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
|
||||
|
||||
const menuCache = new Map<string, Menu | undefined>();
|
||||
const notifyRegister = (event: MenuItemRegisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
registerEmitter.fire(event);
|
||||
};
|
||||
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
unregisterEmitter.fire(event);
|
||||
};
|
||||
|
||||
const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
@@ -117,16 +118,11 @@ export const useMenu = (location: string): Menu | undefined =>
|
||||
|
||||
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
|
||||
listener: (e: MenuItemRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
): Disposable => registerEmitter.subscribe(listener);
|
||||
|
||||
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
|
||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable =>
|
||||
unregisterEmitter.subscribe(listener);
|
||||
|
||||
export const menus: typeof menusApi = {
|
||||
registerMenuItem,
|
||||
|
||||
124
superset-frontend/src/core/navigation/index.test.ts
Normal file
124
superset-frontend/src/core/navigation/index.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Reset module state between tests so currentPage is re-initialized.
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { pathname: '/' },
|
||||
});
|
||||
});
|
||||
|
||||
async function importNavigation() {
|
||||
const mod = await import('./index');
|
||||
return mod;
|
||||
}
|
||||
|
||||
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
// The default pathname ('/') is not enumerated and falls back to home.
|
||||
expect(navigation.getPage()).toBe('home');
|
||||
notifyPageChange('/superset/welcome/');
|
||||
expect(navigation.getPage()).toBe('home');
|
||||
});
|
||||
|
||||
test('getPage derives the page from window.location.pathname', async () => {
|
||||
window.location.pathname = '/superset/dashboard/42/';
|
||||
const { navigation } = await importNavigation();
|
||||
expect(navigation.getPage()).toBe('dashboard');
|
||||
});
|
||||
|
||||
test('notifyPageChange updates the current page type', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
notifyPageChange('/explore/?form_data={}');
|
||||
expect(navigation.getPage()).toBe('explore');
|
||||
});
|
||||
|
||||
test('notifyPageChange fires listeners on page type change', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
notifyPageChange('/superset/dashboard/1/');
|
||||
expect(listener).toHaveBeenCalledWith('dashboard');
|
||||
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
|
||||
window.location.pathname = '/superset/dashboard/1/';
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
navigation.onDidChangePage(listener);
|
||||
|
||||
notifyPageChange('/superset/dashboard/2/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onDidChangePage listener is removed after dispose', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
disposable.dispose();
|
||||
notifyPageChange('/superset/dashboard/1/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sqllab path is matched with and without trailing slash', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/sqllab');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
notifyPageChange('/explore/');
|
||||
notifyPageChange('/sqllab/');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
});
|
||||
|
||||
test('chart and dashboard list pages get their own page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/chart/list/');
|
||||
expect(navigation.getPage()).toBe('chart_list');
|
||||
notifyPageChange('/dashboard/list/');
|
||||
expect(navigation.getPage()).toBe('dashboard_list');
|
||||
});
|
||||
|
||||
test('dataset list and single-dataset pages get distinct page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/tablemodelview/list/');
|
||||
expect(navigation.getPage()).toBe('dataset_list');
|
||||
notifyPageChange('/dataset/42');
|
||||
expect(navigation.getPage()).toBe('dataset');
|
||||
});
|
||||
|
||||
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/sqllab/');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
notifyPageChange('/sqllab/history/');
|
||||
expect(navigation.getPage()).toBe('query_history');
|
||||
notifyPageChange('/savedqueryview/list/');
|
||||
expect(navigation.getPage()).toBe('saved_queries');
|
||||
});
|
||||
|
||||
test('chart/add resolves to explore, not chart_list', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/chart/add');
|
||||
expect(navigation.getPage()).toBe('explore');
|
||||
});
|
||||
79
superset-frontend/src/core/navigation/index.ts
Normal file
79
superset-frontend/src/core/navigation/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `navigation` namespace.
|
||||
*
|
||||
* Backed by browser location — no Redux dependency.
|
||||
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
|
||||
*/
|
||||
|
||||
import type { navigation as navigationApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type Page = navigationApi.Page;
|
||||
|
||||
const pageChangeEmitter = createEventEmitter<Page>();
|
||||
|
||||
function derivePage(pathname: string): Page {
|
||||
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
|
||||
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
|
||||
if (pathname.startsWith('/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/superset/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/chart/add')) return 'explore';
|
||||
if (pathname.startsWith('/chart/list')) return 'chart_list';
|
||||
if (pathname.startsWith('/sqllab/history')) return 'query_history';
|
||||
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
|
||||
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
|
||||
return 'sqllab';
|
||||
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
|
||||
if (pathname.startsWith('/dataset/')) return 'dataset';
|
||||
// The welcome page and any route not explicitly enumerated fall back to home.
|
||||
return 'home';
|
||||
}
|
||||
|
||||
let currentPage: Page | undefined;
|
||||
|
||||
function getOrInitPage(): Page {
|
||||
if (currentPage === undefined) {
|
||||
currentPage = derivePage(window.location.pathname);
|
||||
}
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
/** Called by ExtensionsStartup whenever the React Router location changes. */
|
||||
export const notifyPageChange = (pathname: string): void => {
|
||||
const next = derivePage(pathname);
|
||||
if (next === getOrInitPage()) return;
|
||||
currentPage = next;
|
||||
pageChangeEmitter.fire(next);
|
||||
};
|
||||
|
||||
const getPage: typeof navigationApi.getPage = () => getOrInitPage();
|
||||
|
||||
const onDidChangePage: typeof navigationApi.onDidChangePage = (
|
||||
listener: (page: Page) => void,
|
||||
thisArgs?: any,
|
||||
): Disposable => pageChangeEmitter.subscribe(listener, thisArgs);
|
||||
|
||||
export const navigation: typeof navigationApi = {
|
||||
getPage,
|
||||
onDidChangePage,
|
||||
};
|
||||
@@ -21,6 +21,57 @@ import { AnyAction } from 'redux';
|
||||
import { listenerMiddleware, RootState, store } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
|
||||
type Listener<T> = (e: T) => unknown;
|
||||
|
||||
/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
|
||||
export interface EventEmitter<T> {
|
||||
/** Notifies every current subscriber with `value`. */
|
||||
fire(value: T): void;
|
||||
/** Registers a listener; returns a Disposable that removes it. */
|
||||
subscribe: core.Event<T>;
|
||||
}
|
||||
|
||||
/** An event emitter that also retains the last fired value. */
|
||||
export interface ValueEventEmitter<T> extends EventEmitter<T> {
|
||||
/** Returns the value last passed to {@link fire} (or the initial value). */
|
||||
getCurrent(): T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stateless event emitter. Listeners registered via `event` receive
|
||||
* every subsequent `fire`; a returned Disposable removes the listener.
|
||||
*/
|
||||
export function createEventEmitter<T>(): EventEmitter<T> {
|
||||
const listeners = new Set<Listener<T>>();
|
||||
const subscribe: core.Event<T> = (listener, thisArgs) => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
listeners.add(bound);
|
||||
return { dispose: () => listeners.delete(bound) };
|
||||
};
|
||||
return {
|
||||
fire: value => listeners.forEach(fn => fn(value)),
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a value event emitter seeded with `initial`. Behaves like
|
||||
* {@link createEventEmitter} but also tracks the last fired value, readable
|
||||
* via `getCurrent` — useful for state that is both observed and queried.
|
||||
*/
|
||||
export function createValueEventEmitter<T>(initial: T): ValueEventEmitter<T> {
|
||||
const { fire, subscribe } = createEventEmitter<T>();
|
||||
let current = initial;
|
||||
return {
|
||||
fire: value => {
|
||||
current = value;
|
||||
fire(value);
|
||||
},
|
||||
subscribe,
|
||||
getCurrent: () => current,
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionListener<V>(
|
||||
predicate: AnyListenerPredicate<RootState>,
|
||||
listener: (v: V) => void,
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { views as viewsApi } from '@apache-superset/core';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type View = viewsApi.View;
|
||||
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
|
||||
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
|
||||
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
|
||||
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
|
||||
|
||||
const viewsCache = new Map<string, View[] | undefined>();
|
||||
const notifyRegister = (event: ViewRegisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
registerEmitter.fire(event);
|
||||
};
|
||||
const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
unregisterEmitter.fire(event);
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
@@ -116,17 +117,11 @@ export const useViews = (location: string): View[] | undefined =>
|
||||
|
||||
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
|
||||
listener: (e: ViewRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
): Disposable => registerEmitter.subscribe(listener);
|
||||
|
||||
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
|
||||
listener: (e: ViewUnregisteredEvent) => void,
|
||||
): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
): Disposable => unregisterEmitter.subscribe(listener);
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
|
||||
@@ -299,7 +299,10 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
|
||||
);
|
||||
});
|
||||
|
||||
test('checkIsApplyDisabled handles filter count mismatch', () => {
|
||||
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
|
||||
// Regression: when a required filter's default isn't applied (Applied missing
|
||||
// the entry) and the user types a value, Selected gains an entry Applied
|
||||
// doesn't have. Apply must be enabled so the user can commit the value.
|
||||
const dataMaskSelected: DataMaskStateWithId = {
|
||||
'filter-1': {
|
||||
id: 'filter-1',
|
||||
@@ -322,7 +325,7 @@ test('checkIsApplyDisabled handles filter count mismatch', () => {
|
||||
const filters = [createFilter('filter-1'), createFilter('filter-2')];
|
||||
|
||||
expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe(
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -74,13 +74,9 @@ export const checkIsApplyDisabled = (
|
||||
const selectedExtraFormData = getOnlyExtraFormData(dataMaskSelected);
|
||||
const appliedExtraFormData = getOnlyExtraFormData(dataMaskApplied);
|
||||
|
||||
// Check counts first
|
||||
const selectedCount = Object.keys(selectedExtraFormData).length;
|
||||
const appliedCount = Object.keys(appliedExtraFormData).length;
|
||||
|
||||
if (selectedCount !== appliedCount) return true;
|
||||
|
||||
// Check for changes
|
||||
// Check for changes. ignoreUndefined drops empty keys on both sides so that
|
||||
// a filter present in Selected with a real value but absent (or undefined)
|
||||
// in Applied is correctly detected as a change.
|
||||
const dataEqual = areObjectsEqual(
|
||||
selectedExtraFormData,
|
||||
appliedExtraFormData,
|
||||
|
||||
@@ -42,7 +42,9 @@ const isUserDashboardOwner = (
|
||||
user: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
) =>
|
||||
isUserWithPermissionsAndRoles(user) &&
|
||||
dashboard.owners.some(owner => owner.id === user.userId);
|
||||
[...dashboard.owners, ...(dashboard.extra_owners ?? [])].some(
|
||||
owner => owner.id === user.userId,
|
||||
);
|
||||
|
||||
export const canUserEditDashboard = (
|
||||
dashboard: Dashboard,
|
||||
|
||||
@@ -121,6 +121,38 @@ test('when user edits a filter without changing targets, their selection is pres
|
||||
);
|
||||
});
|
||||
|
||||
test('when a required range filter was cleared to [null, null], modifying it applies the new default instead of the cleared state', () => {
|
||||
// Regression for the PR #40470 review: [null, null] is a range filter's
|
||||
// canonical "cleared" value. It must count as "no value" so the empty state
|
||||
// does not wipe a newly-defined default — consistent with `loadedHasValue`
|
||||
// in fillNativeFilters.
|
||||
const initialState: DataMaskStateWithId = {
|
||||
'NATIVE_FILTER-1': {
|
||||
id: 'NATIVE_FILTER-1',
|
||||
...getInitialDataMask('NATIVE_FILTER-1'),
|
||||
filterState: { value: [null, null] },
|
||||
},
|
||||
};
|
||||
|
||||
const oldFilters = {
|
||||
'NATIVE_FILTER-1': createFilter('NATIVE_FILTER-1', 'col_a', {
|
||||
enableEmptyFilter: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const modifiedFilter: Filter = {
|
||||
...createFilter('NATIVE_FILTER-1', 'col_a', { enableEmptyFilter: true }),
|
||||
defaultDataMask: { filterState: { value: [10, 20] } },
|
||||
};
|
||||
|
||||
const action = createModifyAction(modifiedFilter, oldFilters);
|
||||
|
||||
const result = reducer(initialState, action);
|
||||
|
||||
// The cleared [null, null] state must not be preserved; the new default wins.
|
||||
expect(result['NATIVE_FILTER-1']?.filterState?.value).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
// Runtime data from the server can contain null entries in
|
||||
// chart_customization_config even though the TS type does not include | null
|
||||
// yet. These helpers build HYDRATE_DASHBOARD actions that mirror that reality.
|
||||
|
||||
@@ -109,10 +109,50 @@ function fillNativeFilters(
|
||||
) {
|
||||
filterConfig.forEach((filter: Filter) => {
|
||||
const dataMask = initialDataMask || {};
|
||||
const loaded = dataMask[filter.id];
|
||||
|
||||
// The shallow spread of `loaded` would override `filter.defaultDataMask`
|
||||
// even when the loaded mask is incomplete (e.g. a permalink captured
|
||||
// mid-initialization), wiping out a valid default. For REQUIRED filters
|
||||
// with a default, fall back to the default when the loaded mask carries
|
||||
// no real value OR is missing the extraFormData needed to filter charts.
|
||||
// Non-required filters keep current behavior so a user's explicit clear
|
||||
// isn't undone.
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
const loadedValue = loaded?.filterState?.value;
|
||||
const loadedHasValue =
|
||||
loadedValue !== undefined &&
|
||||
loadedValue !== null &&
|
||||
!(
|
||||
// Treat all-null arrays (range filters use [null, null] as their
|
||||
// canonical cleared value) and empty arrays as "no value".
|
||||
(Array.isArray(loadedValue) && loadedValue.every(v => v === null))
|
||||
);
|
||||
const loadedHasExtraFormData =
|
||||
!!loaded?.extraFormData && Object.keys(loaded.extraFormData).length > 0;
|
||||
const defaultHasExtraFormData =
|
||||
!!filter.defaultDataMask?.extraFormData &&
|
||||
Object.keys(filter.defaultDataMask.extraFormData).length > 0;
|
||||
// Restore when:
|
||||
// (1) loaded value is empty — classic "default wiped by stale permalink", OR
|
||||
// (2) loaded has a value but no extraFormData and the default does — the
|
||||
// "value present in UI but not applied to charts" gap-window case where
|
||||
// a permalink was captured before FilterValue produced extraFormData.
|
||||
const shouldRestoreDefault =
|
||||
isRequired &&
|
||||
!!filter.defaultDataMask &&
|
||||
(!loadedHasValue || (!loadedHasExtraFormData && defaultHasExtraFormData));
|
||||
|
||||
mergedDataMask[filter.id] = {
|
||||
...getInitialDataMask(filter.id), // take initial data
|
||||
...filter.defaultDataMask, // if something new came from BE - take it
|
||||
...dataMask[filter.id],
|
||||
...loaded,
|
||||
...(shouldRestoreDefault
|
||||
? {
|
||||
filterState: filter.defaultDataMask?.filterState,
|
||||
extraFormData: filter.defaultDataMask?.extraFormData,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (
|
||||
currentFilters &&
|
||||
@@ -155,12 +195,28 @@ function updateDataMaskForFilterChanges(
|
||||
// Check if targets are equal
|
||||
const areTargetsEqual = isEqual(prevFilterDef?.targets, filter?.targets);
|
||||
|
||||
// Preserve state only if filter exists, has enableEmptyFilter=true and targets match
|
||||
// For required filters, only preserve existing state when it actually has
|
||||
// a value — otherwise the empty existing state would wipe the (possibly
|
||||
// newly-defined) default. defaultToFirstItem filters keep the old behavior:
|
||||
// FilterValue re-resolves the first item at runtime, so preserving the
|
||||
// mid-init empty state is fine.
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
const isFirstItem = !!filter.controlValues?.defaultToFirstItem;
|
||||
const existingValue = existingFilter?.filterState?.value;
|
||||
const hasExistingValue =
|
||||
existingValue !== undefined &&
|
||||
existingValue !== null &&
|
||||
// Treat all-null arrays (range filters use [null, null] as their
|
||||
// canonical cleared value) and empty arrays as "no value", consistent
|
||||
// with `loadedHasValue` in fillNativeFilters above. `[].every()` is
|
||||
// true, so this also covers the empty-array case.
|
||||
!(Array.isArray(existingValue) && existingValue.every(v => v === null));
|
||||
|
||||
const shouldPreserveState =
|
||||
existingFilter &&
|
||||
areTargetsEqual &&
|
||||
(filter.controlValues?.enableEmptyFilter ||
|
||||
filter.controlValues?.defaultToFirstItem);
|
||||
(isRequired || isFirstItem) &&
|
||||
(isFirstItem || hasExistingValue);
|
||||
|
||||
mergedDataMask[filter.id] = {
|
||||
...getInitialDataMask(filter.id),
|
||||
|
||||
@@ -177,9 +177,11 @@ export const hydrateExplore =
|
||||
can_copy_clipboard: granularExport
|
||||
? findPermission('can_copy_clipboard', 'Superset', user?.roles)
|
||||
: findPermission('can_csv', 'Superset', user?.roles),
|
||||
can_overwrite: ensureIsArray(slice?.owners).includes(
|
||||
user?.userId as number,
|
||||
),
|
||||
can_overwrite:
|
||||
ensureIsArray(slice?.owners).includes(user?.userId as number) ||
|
||||
ensureIsArray(metadata?.extra_owners).some(
|
||||
(o: { id: number }) => o.id === user?.userId,
|
||||
),
|
||||
isDatasourceMetaLoading: false,
|
||||
isStarred: false,
|
||||
triggerRender: false,
|
||||
|
||||
@@ -75,6 +75,7 @@ interface SaveModalProps extends RouteComponentProps {
|
||||
alert?: string;
|
||||
sliceName?: string;
|
||||
slice?: Record<string, any>;
|
||||
can_overwrite?: boolean;
|
||||
datasource?: Record<string, any>;
|
||||
dashboardId: '' | number | null;
|
||||
isVisible: boolean;
|
||||
@@ -128,7 +129,8 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
|
||||
canOverwriteSlice(): boolean {
|
||||
return (
|
||||
(isUserAdmin(this.props.user) ||
|
||||
(this.props.can_overwrite ||
|
||||
isUserAdmin(this.props.user) ||
|
||||
this.props.slice?.owners?.includes(this.props.user.userId)) &&
|
||||
!this.props.slice?.is_managed_externally
|
||||
);
|
||||
@@ -819,6 +821,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
interface StateProps {
|
||||
datasource: any;
|
||||
slice: any;
|
||||
can_overwrite: boolean;
|
||||
user: UserWithPermissionsAndRoles;
|
||||
dashboards: any;
|
||||
alert: any;
|
||||
@@ -833,6 +836,7 @@ function mapStateToProps({
|
||||
return {
|
||||
datasource: explore.datasource,
|
||||
slice: explore.slice,
|
||||
can_overwrite: explore.can_overwrite,
|
||||
user,
|
||||
dashboards: saveModal.dashboards,
|
||||
alert: saveModal.saveModalAlert,
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface ExplorePageInitialData {
|
||||
created_on_humanized: string;
|
||||
changed_on_humanized: string;
|
||||
owners: string[];
|
||||
extra_owners?: { id: number; first_name: string; last_name: string }[];
|
||||
created_by?: string;
|
||||
changed_by?: string;
|
||||
color_namespace?: string;
|
||||
|
||||
@@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
|
||||
version: '1.0.0',
|
||||
dependencies: [],
|
||||
remoteEntry: '',
|
||||
extensionDependencies: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ afterEach(() => {
|
||||
test('renders without crashing', () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
// Verify the global superset object is set up
|
||||
expect((window as any).superset).toBeDefined();
|
||||
expect((window as any).superset.authentication).toBeDefined();
|
||||
expect((window as any).superset.chat).toBeDefined();
|
||||
expect((window as any).superset.core).toBeDefined();
|
||||
expect((window as any).superset.commands).toBeDefined();
|
||||
expect((window as any).superset.extensions).toBeDefined();
|
||||
@@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
test('does not set up global superset object when user is not logged in', async () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => {
|
||||
|
||||
const { rerender } = render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
|
||||
</ExtensionsStartup>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,58 +16,78 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
authentication,
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
import { notifyPageChange } from 'src/core/navigation';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from 'src/views/store';
|
||||
import ExtensionsLoader from './ExtensionsLoader';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
superset: {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
commands: typeof commands;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
};
|
||||
}
|
||||
}
|
||||
import 'src/extensions/Namespaces';
|
||||
|
||||
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const prevPathname = useRef<string | null>(null);
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ user }) => user.userId,
|
||||
);
|
||||
|
||||
// Notify the navigation namespace on every route change.
|
||||
useEffect(() => {
|
||||
if (userId == null) return;
|
||||
if (prevPathname.current !== location.pathname) {
|
||||
prevPathname.current = location.pathname;
|
||||
notifyPageChange(location.pathname);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Provide the implementations for @apache-superset/core
|
||||
// Log unhandled rejections that may originate from extension code.
|
||||
// Registered once for the lifetime of the app; does not suppress the
|
||||
// browser's default error surfacing so host error reporting is unaffected.
|
||||
useEffect(() => {
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
logging.error('[extensions] Unhandled rejection:', event.reason);
|
||||
};
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'unhandledrejection',
|
||||
handleUnhandledRejection,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Provide the implementations for @apache-superset/core.
|
||||
// Namespaces are listed explicitly — do not spread the core package here,
|
||||
// as that would leak un-contracted symbols onto window.superset.
|
||||
window.superset = {
|
||||
...supersetCore,
|
||||
authentication,
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
};
|
||||
|
||||
60
superset-frontend/src/extensions/Namespaces.ts
Normal file
60
superset-frontend/src/extensions/Namespaces.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Global `window.superset` type augmentation.
|
||||
*
|
||||
* Lives in its own module (rather than inline in ExtensionsStartup) so every
|
||||
* file that reads or writes `window.superset` — notably ExtensionsLoader —
|
||||
* sees the type regardless of how files are batched during compilation. Both
|
||||
* the startup component and the loader import this module for its side effect.
|
||||
*/
|
||||
|
||||
import type {
|
||||
authentication,
|
||||
chat,
|
||||
commands,
|
||||
core,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
|
||||
/** The host namespaces exposed to extensions on `window.superset`. */
|
||||
export interface Namespaces {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
chat: typeof chat;
|
||||
commands: typeof commands;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
navigation: typeof navigation;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
superset: Namespaces;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export interface Dashboard {
|
||||
changed_on: string;
|
||||
charts: string[]; // just chart names, unfortunately...
|
||||
owners: Owner[];
|
||||
extra_owners?: Owner[];
|
||||
roles: Role[];
|
||||
theme?: {
|
||||
id: number;
|
||||
|
||||
@@ -38,7 +38,9 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
|
||||
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
|
||||
import ChatMount from 'src/components/ChatMount';
|
||||
import { RootContextProviders } from './RootContextProviders';
|
||||
import { ScrollToTop } from './ScrollToTop';
|
||||
|
||||
@@ -112,6 +114,13 @@ const App = () => (
|
||||
</Route>
|
||||
))}
|
||||
</Switch>
|
||||
{/*
|
||||
The singleton chat slot. Rendered as a sibling of the route
|
||||
Switch — inside ExtensionsStartup so chat extensions have been
|
||||
loaded and registered, but outside the Switch so the chat persists
|
||||
across route changes.
|
||||
*/}
|
||||
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatMount />}
|
||||
</ExtensionsStartup>
|
||||
<ToastContainer />
|
||||
</RootContextProviders>
|
||||
|
||||
332
superset-websocket/package-lock.json
generated
332
superset-websocket/package-lock.json
generated
@@ -26,7 +26,7 @@
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
@@ -1883,16 +1883,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
|
||||
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
|
||||
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1907,6 +1907,175 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
|
||||
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.0",
|
||||
"@typescript-eslint/types": "^8.61.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
|
||||
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
|
||||
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
|
||||
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
|
||||
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.61.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
|
||||
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
|
||||
@@ -6211,6 +6380,31 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
|
||||
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
@@ -7951,16 +8145,109 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/parser": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
|
||||
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
|
||||
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
|
||||
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.0",
|
||||
"@typescript-eslint/types": "^8.61.0",
|
||||
"debug": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/scope-manager": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
|
||||
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
|
||||
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@typescript-eslint/types": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
|
||||
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
|
||||
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/project-service": "8.61.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/visitor-keys": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
|
||||
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/project-service": {
|
||||
@@ -11030,6 +11317,21 @@
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/parser": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
|
||||
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"debug": "^4.4.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uglify-js": {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
|
||||
@@ -21,7 +21,7 @@ from io import BytesIO
|
||||
from typing import Any, cast, Optional
|
||||
from zipfile import is_zipfile, ZipFile
|
||||
|
||||
from flask import redirect, request, Response, send_file, url_for
|
||||
from flask import current_app, redirect, request, Response, send_file, url_for
|
||||
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
|
||||
from flask_appbuilder.hooks import before_request
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
@@ -310,6 +310,8 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
try:
|
||||
dash = ChartDAO.get_by_id_or_uuid(id_or_uuid)
|
||||
result = self.chart_get_response_schema.dump(dash)
|
||||
if resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"):
|
||||
result["extra_owners"] = resolver(dash)
|
||||
return self.response(200, result=result)
|
||||
except ChartNotFoundError:
|
||||
return self.response_404()
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# under the License.
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app
|
||||
from flask_babel import lazy_gettext as _
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import aliased
|
||||
@@ -109,7 +110,22 @@ class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
query = query.join(
|
||||
models.Database, table_alias.database_id == models.Database.id
|
||||
)
|
||||
return query.filter(get_dataset_access_filters(self.model))
|
||||
|
||||
extra_access_filters = []
|
||||
extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
|
||||
if extra_charts_filter := extra_filters.get("charts"):
|
||||
user_id = get_user_id()
|
||||
if user_id:
|
||||
extra_access_filters.append(
|
||||
self.model.id.in_(extra_charts_filter(user_id))
|
||||
)
|
||||
|
||||
return query.filter(
|
||||
or_(
|
||||
get_dataset_access_filters(self.model),
|
||||
*extra_access_filters,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ChartHasCreatedByFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
|
||||
@@ -19,7 +19,7 @@ from datetime import datetime
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import g
|
||||
from flask import current_app, g
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow import ValidationError
|
||||
|
||||
@@ -58,7 +58,10 @@ class CreateChartCommand(CreateMixin, BaseCommand):
|
||||
self.validate()
|
||||
self._properties["last_saved_at"] = datetime.now()
|
||||
self._properties["last_saved_by"] = g.user
|
||||
return ChartDAO.create(attributes=self._properties)
|
||||
chart = ChartDAO.create(attributes=self._properties)
|
||||
if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
|
||||
after_create(chart, "chart")
|
||||
return chart
|
||||
|
||||
def validate(self) -> None:
|
||||
exceptions = []
|
||||
|
||||
@@ -18,6 +18,7 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import current_app
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow import ValidationError
|
||||
|
||||
@@ -52,6 +53,8 @@ class CreateDashboardCommand(CreateMixin, BaseCommand):
|
||||
dashboard,
|
||||
data=json.loads(json_metadata),
|
||||
)
|
||||
if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
|
||||
after_create(dashboard, "dashboard")
|
||||
return dashboard
|
||||
|
||||
def validate(self) -> None:
|
||||
|
||||
@@ -19,7 +19,7 @@ import logging
|
||||
from abc import ABC
|
||||
from typing import Any, cast, Optional
|
||||
|
||||
from flask import request
|
||||
from flask import current_app, request
|
||||
from flask_babel import lazy_gettext as _
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
@@ -160,10 +160,15 @@ class GetExploreCommand(BaseCommand, ABC):
|
||||
metadata = None
|
||||
|
||||
if slc:
|
||||
extra_owners = []
|
||||
if resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"):
|
||||
extra_owners = resolver(slc)
|
||||
|
||||
metadata = {
|
||||
"created_on_humanized": slc.created_on_humanized,
|
||||
"changed_on_humanized": slc.changed_on_humanized,
|
||||
"owners": [owner.get_full_name() for owner in slc.owners],
|
||||
"extra_owners": extra_owners,
|
||||
"dashboards": [
|
||||
{"id": dashboard.id, "dashboard_title": dashboard.dashboard_title}
|
||||
for dashboard in slc.dashboards
|
||||
|
||||
@@ -1157,6 +1157,7 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
|
||||
self._scheduled_dttm = scheduled_dttm
|
||||
self._execution_id = UUID(task_id)
|
||||
|
||||
@transaction()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.validate()
|
||||
@@ -1169,21 +1170,6 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
|
||||
)
|
||||
user = security_manager.find_user(username)
|
||||
|
||||
with override_user(user):
|
||||
# Pre-commit any permalink rows before the state machine's
|
||||
# @transaction() opens. When called inside a transaction,
|
||||
# CreateDashboardPermalinkCommand only flushes (not commits),
|
||||
# leaving the row invisible to Playwright's separate DB
|
||||
# connection. Running get_dashboard_urls() here — outside any
|
||||
# transaction — lets the command commit normally. The state
|
||||
# machine's inner call to get_dashboard_urls() hits get_entry()
|
||||
# for the same deterministic UUID and returns the
|
||||
# already-committed row without a second INSERT.
|
||||
if self._model.dashboard_id:
|
||||
BaseReportState(
|
||||
self._model, self._scheduled_dttm, self._execution_id
|
||||
).get_dashboard_urls()
|
||||
|
||||
start_time = datetime.utcnow()
|
||||
with override_user(user):
|
||||
ReportScheduleStateMachine(
|
||||
|
||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
from collections import Counter
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
from flask import g
|
||||
from flask import current_app, g
|
||||
from flask_appbuilder.security.sqla.models import Role, User
|
||||
|
||||
from superset import security_manager
|
||||
@@ -58,8 +58,11 @@ def populate_owner_list(
|
||||
if not owner_ids and default_to_user:
|
||||
return [g.user]
|
||||
if not (security_manager.is_admin() or get_user_id() in owner_ids):
|
||||
# make sure non-admins can't remove themselves as owner by mistake
|
||||
owners.append(g.user)
|
||||
# Make sure non-admins can't remove themselves as owner by mistake.
|
||||
# Skip auto-add when an EXTRA_OWNERS_RESOLVER is configured — the
|
||||
# resolver handles access independently of the owners list.
|
||||
if not current_app.config.get("EXTRA_OWNERS_RESOLVER"):
|
||||
owners.append(g.user)
|
||||
for owner_id in owner_ids:
|
||||
owner = security_manager.get_user_by_id(owner_id)
|
||||
if not owner:
|
||||
|
||||
@@ -2632,6 +2632,27 @@ class ExtraDynamicQueryFilters(TypedDict, total=False):
|
||||
EXTRA_DYNAMIC_QUERY_FILTERS: ExtraDynamicQueryFilters = {}
|
||||
|
||||
|
||||
# Extra access query filters inject additional OR conditions into
|
||||
# ChartFilter and DashboardAccessFilter, enabling external systems
|
||||
# (e.g. folder permissions) to grant asset visibility.
|
||||
# The callable receives the current user ID and returns a subquery of asset IDs.
|
||||
class ExtraAccessQueryFilters(TypedDict, total=False):
|
||||
charts: Callable[[int], Query]
|
||||
dashboards: Callable[[int], Query]
|
||||
|
||||
|
||||
# Extension hooks for deployments to plug in custom access logic.
|
||||
# Additional query filters for chart/dashboard list views.
|
||||
EXTRA_ACCESS_QUERY_FILTERS: ExtraAccessQueryFilters = {}
|
||||
# Bypass raise_for_access for specific assets. Return True to skip checks.
|
||||
EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
|
||||
# Resolve extra owners for a resource. Also used for ownership checks and
|
||||
# to skip auto-adding the current user to owners on create.
|
||||
EXTRA_OWNERS_RESOLVER: Callable[..., list[Any]] | None = None
|
||||
# Post-create hook for charts/dashboards. Receives (model, asset_type).
|
||||
AFTER_ASSET_CREATE: Callable[[Any, str], None] | None = None
|
||||
|
||||
|
||||
# The migrations that add catalog permissions might take a considerably long time
|
||||
# to execute as it has to create permissions to all schemas and catalogs from all
|
||||
# other catalogs accessible by the credentials. This flag allows to skip the
|
||||
|
||||
@@ -519,6 +519,8 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
||||
schema = self.dashboard_get_response_schema
|
||||
|
||||
result = schema.dump(dash)
|
||||
if resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"):
|
||||
result["extra_owners"] = resolver(dash)
|
||||
add_extra_log_payload(
|
||||
dashboard_id=dash.id, action=f"{self.__class__.__name__}.get"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import g
|
||||
from flask import current_app, g
|
||||
from flask_appbuilder.security.sqla.models import Role
|
||||
from flask_babel import lazy_gettext as _
|
||||
from sqlalchemy import and_, or_
|
||||
@@ -182,11 +182,21 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
|
||||
|
||||
feature_flagged_filters.append(condition)
|
||||
|
||||
extra_access_filters = []
|
||||
extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
|
||||
if extra_dashboards_filter := extra_filters.get("dashboards"):
|
||||
user_id = get_user_id()
|
||||
if user_id:
|
||||
extra_access_filters.append(
|
||||
Dashboard.id.in_(extra_dashboards_filter(user_id))
|
||||
)
|
||||
|
||||
query = query.filter(
|
||||
or_(
|
||||
Dashboard.id.in_(owner_ids_query),
|
||||
Dashboard.id.in_(datasource_perm_query),
|
||||
*feature_flagged_filters,
|
||||
*extra_access_filters,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -238,6 +238,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
|
||||
manifest = extension.manifest
|
||||
extension_data: dict[str, Any] = {
|
||||
"id": manifest.id,
|
||||
"publisher": manifest.publisher,
|
||||
"name": extension.name,
|
||||
"version": extension.version,
|
||||
"description": manifest.description or "",
|
||||
|
||||
@@ -162,6 +162,7 @@ Alerts & Reports:
|
||||
Dataset Management:
|
||||
- list_datasets: List datasets with advanced filters (1-based pagination)
|
||||
- get_dataset_info: Get detailed dataset information by ID (includes columns/metrics)
|
||||
- create_dataset: Register a physical table as a dataset against an existing DB connection (requires write access)
|
||||
- create_virtual_dataset: Save a SQL query as a virtual dataset for charting (requires write access)
|
||||
- query_dataset: Query a dataset using its semantic layer (saved metrics, dimensions, filters) without needing a saved chart
|
||||
|
||||
@@ -422,7 +423,7 @@ Input format:
|
||||
{_feature_availability}Permission Awareness:
|
||||
{_instance_info_role_bullet}- ALWAYS check the user's roles BEFORE suggesting write operations (creating datasets,
|
||||
charts, or dashboards). SQL execution is a separate permission — see execute_sql below.
|
||||
- Write tools (generate_chart, generate_dashboard, update_chart, create_virtual_dataset,
|
||||
- Write tools (generate_chart, generate_dashboard, update_chart, create_dataset, create_virtual_dataset,
|
||||
save_sql_query, add_chart_to_existing_dashboard, update_chart_preview) require write
|
||||
permissions. These tools are only listed for users who have the necessary access.
|
||||
If a write tool does not appear in the tool list, the current user lacks write access.
|
||||
@@ -631,9 +632,9 @@ def create_mcp_app(
|
||||
# Create default MCP instance for backward compatibility
|
||||
mcp = create_mcp_app()
|
||||
|
||||
# Initialize MCP dependency injection BEFORE importing tools/prompts
|
||||
# This replaces the abstract @tool and @prompt decorators in superset_core.api.mcp
|
||||
# with concrete implementations that can register with the mcp instance
|
||||
# Initialize MCP dependency injection BEFORE importing tools/prompts.
|
||||
# Replaces the stub @tool/@prompt decorators in superset_core.mcp.decorators
|
||||
# with concrete implementations bound to this mcp instance.
|
||||
from superset.core.mcp.core_mcp_injection import ( # noqa: E402
|
||||
initialize_core_mcp_dependencies,
|
||||
)
|
||||
@@ -658,6 +659,7 @@ warnings.filterwarnings(
|
||||
module=r"google\..*",
|
||||
)
|
||||
|
||||
|
||||
# Import all MCP tools to register them with the mcp instance
|
||||
# NOTE: Always add new tool imports here when creating new MCP tools.
|
||||
# Tools use the @tool decorator from `superset-core` and register automatically
|
||||
@@ -698,6 +700,7 @@ from superset.mcp_service.database.tool import ( # noqa: F401, E402
|
||||
list_databases,
|
||||
)
|
||||
from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
|
||||
create_dataset,
|
||||
create_virtual_dataset,
|
||||
get_dataset_info,
|
||||
list_datasets,
|
||||
@@ -835,8 +838,9 @@ def init_fastmcp_server(
|
||||
Returns:
|
||||
The global FastMCP instance configured with the provided settings
|
||||
"""
|
||||
# Read branding from Flask config's APP_NAME
|
||||
from superset.mcp_service.flask_singleton import app as flask_app
|
||||
# circular import: flask_singleton imports from superset.extensions which
|
||||
# re-enters mcp_service during startup; must stay lazy inside the function.
|
||||
from superset.mcp_service.flask_singleton import app as flask_app # noqa: PLC0415
|
||||
|
||||
# Derive branding from Superset's APP_NAME config (defaults to "Superset")
|
||||
app_name = flask_app.config.get("APP_NAME", "Superset")
|
||||
|
||||
@@ -21,7 +21,7 @@ Pydantic schemas for dataset-related responses
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
from pydantic import (
|
||||
@@ -325,8 +325,6 @@ class DatasetError(BaseModel):
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "DatasetError":
|
||||
"""Create a standardized DatasetError with timestamp."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return cls(
|
||||
error=error,
|
||||
error_type=error_type,
|
||||
@@ -410,6 +408,76 @@ class GetDatasetInfoRequest(MetadataCacheControl):
|
||||
return parsed
|
||||
|
||||
|
||||
class CreateDatasetRequest(BaseModel):
|
||||
"""Request schema for create_dataset to register a physical table as a dataset."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
database_id: Annotated[
|
||||
int,
|
||||
Field(
|
||||
description="ID of the database connection to register the table against"
|
||||
),
|
||||
]
|
||||
schema_: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
alias="schema",
|
||||
serialization_alias="schema",
|
||||
max_length=250,
|
||||
description="Schema (namespace) where the table lives, e.g. 'public'. "
|
||||
"Omit or pass None for databases without schema namespaces (e.g. SQLite).",
|
||||
),
|
||||
]
|
||||
catalog: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=250,
|
||||
description="Catalog where the table lives. Omit for databases without "
|
||||
"catalog support.",
|
||||
),
|
||||
]
|
||||
table_name: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=250,
|
||||
description="Name of the physical table to register as a dataset",
|
||||
),
|
||||
]
|
||||
owners: Annotated[
|
||||
List[int] | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Optional list of owner user IDs. "
|
||||
"Defaults to the calling user.",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("schema_", "catalog", mode="before")
|
||||
@classmethod
|
||||
def _normalize_optional_str(cls, v: object) -> object:
|
||||
"""Strip whitespace and convert blank strings to None.
|
||||
|
||||
Non-string values pass through unchanged so Pydantic's type validation
|
||||
rejects them, rather than silently treating a malformed value (e.g. an
|
||||
int or dict) as an omitted namespace.
|
||||
"""
|
||||
if isinstance(v, str):
|
||||
return v.strip() or None
|
||||
return v
|
||||
|
||||
@field_validator("table_name", mode="before")
|
||||
@classmethod
|
||||
def _strip_table_name(cls, v: object) -> object:
|
||||
"""Strip leading/trailing whitespace from table_name."""
|
||||
if isinstance(v, str):
|
||||
return v.strip()
|
||||
return v
|
||||
|
||||
|
||||
class CreateVirtualDatasetRequest(BaseModel):
|
||||
"""Request schema for create_virtual_dataset."""
|
||||
|
||||
@@ -734,7 +802,7 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
|
||||
if isinstance(params, str):
|
||||
try:
|
||||
params = json.loads(params)
|
||||
except Exception:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
params = None
|
||||
columns = [
|
||||
TableColumnInfo(
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .create_dataset import create_dataset
|
||||
from .create_virtual_dataset import create_virtual_dataset
|
||||
from .get_dataset_info import get_dataset_info
|
||||
from .list_datasets import list_datasets
|
||||
from .query_dataset import query_dataset
|
||||
|
||||
__all__ = [
|
||||
"create_dataset",
|
||||
"create_virtual_dataset",
|
||||
"list_datasets",
|
||||
"get_dataset_info",
|
||||
"list_datasets",
|
||||
"query_dataset",
|
||||
]
|
||||
|
||||
171
superset/mcp_service/dataset/tool/create_dataset.py
Normal file
171
superset/mcp_service/dataset/tool/create_dataset.py
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Create dataset FastMCP tool
|
||||
|
||||
Registers a physical table as a Superset dataset against an existing
|
||||
database connection — the programmatic equivalent of Data → Datasets → +Dataset.
|
||||
Returns the same DatasetInfo shape as get_dataset_info so the caller can feed
|
||||
the resulting dataset_id directly into generate_chart.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.commands.dataset.create import CreateDatasetCommand
|
||||
from superset.commands.dataset.exceptions import (
|
||||
DatasetCreateFailedError,
|
||||
DatasetInvalidError,
|
||||
)
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.dataset.schemas import (
|
||||
CreateDatasetRequest,
|
||||
DatasetError,
|
||||
DatasetInfo,
|
||||
serialize_dataset_object,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _classify_invalid_error(exc: DatasetInvalidError) -> DatasetError:
|
||||
"""Map DatasetInvalidError sub-exceptions to typed DatasetError responses."""
|
||||
classnames = exc.get_list_classnames()
|
||||
messages = exc.normalized_messages()
|
||||
if "DatabaseNotFoundValidationError" in classnames:
|
||||
return DatasetError.create(
|
||||
error="Database not found", error_type="DatabaseNotFoundError"
|
||||
)
|
||||
if "DatasetDataAccessIsNotAllowed" in classnames:
|
||||
return DatasetError.create(
|
||||
error="Access denied", error_type="AccessDeniedError"
|
||||
)
|
||||
if "DatasetExistsValidationError" in classnames:
|
||||
return DatasetError.create(error=str(messages), error_type="DatasetExistsError")
|
||||
if "TableNotFoundValidationError" in classnames:
|
||||
return DatasetError.create(error=str(messages), error_type="TableNotFoundError")
|
||||
# Other DatasetInvalidError sub-types are returned as generic ValidationError.
|
||||
# Add explicit branches here when callers need to distinguish them.
|
||||
return DatasetError.create(error=str(messages), error_type="ValidationError")
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Dataset",
|
||||
method_permission_name="write",
|
||||
annotations=ToolAnnotations(
|
||||
title="Register physical table as dataset",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def create_dataset(
|
||||
request: CreateDatasetRequest, ctx: Context
|
||||
) -> DatasetInfo | DatasetError:
|
||||
"""Register a physical table as a Superset dataset.
|
||||
|
||||
Wraps POST /api/v1/dataset/ — the same endpoint the UI uses when you click
|
||||
Data → Datasets → +Dataset. Returns full dataset metadata (same shape as
|
||||
get_dataset_info) so you can pass the resulting dataset_id straight into
|
||||
generate_chart.
|
||||
|
||||
Required fields:
|
||||
- database_id: ID of the existing database connection
|
||||
- table_name: Exact name of the physical table to register
|
||||
|
||||
Optional fields:
|
||||
- schema: Schema/namespace where the table lives (e.g. "public"). Omit for
|
||||
databases without schema namespaces (e.g. SQLite).
|
||||
- catalog: Catalog where the table lives. Omit for databases without catalog
|
||||
support.
|
||||
- owners: List of user IDs to set as owners (defaults to calling user)
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": "orders"
|
||||
}
|
||||
```
|
||||
|
||||
Returns DatasetInfo on success or DatasetError on failure.
|
||||
Use list_databases to find the correct database_id.
|
||||
"""
|
||||
schema = request.schema_
|
||||
table_name = request.table_name
|
||||
catalog = request.catalog
|
||||
|
||||
await ctx.info(
|
||||
"Registering physical table as dataset: database_id=%s, table=%s"
|
||||
% (request.database_id, f"{schema}.{table_name}" if schema else table_name)
|
||||
)
|
||||
|
||||
try:
|
||||
dataset_properties: dict[str, object] = {
|
||||
"database": request.database_id,
|
||||
"table_name": table_name,
|
||||
}
|
||||
if schema is not None:
|
||||
dataset_properties["schema"] = schema
|
||||
if catalog is not None:
|
||||
dataset_properties["catalog"] = catalog
|
||||
if request.owners is not None:
|
||||
dataset_properties["owners"] = request.owners
|
||||
|
||||
with event_logger.log_context(action="mcp.create_dataset.create"):
|
||||
dataset = CreateDatasetCommand(dataset_properties).run()
|
||||
|
||||
result = serialize_dataset_object(dataset)
|
||||
if result is None:
|
||||
return DatasetError.create(
|
||||
error="Dataset was created but could not be serialized",
|
||||
error_type="SerializationError",
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Dataset registered: id=%s, table=%s"
|
||||
% (
|
||||
dataset.id,
|
||||
f"{schema}.{table_name}" if schema else table_name,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
except DatasetInvalidError as exc:
|
||||
# CreateDatasetCommand.validate() collects validation errors into
|
||||
# DatasetInvalidError.exceptions, never raising them directly.
|
||||
# Inspect the wrapped class names for a typed response.
|
||||
error_response = _classify_invalid_error(exc)
|
||||
await ctx.warning(
|
||||
"Dataset validation failed (%s): %s"
|
||||
% (error_response.error_type, error_response.error)
|
||||
)
|
||||
return error_response
|
||||
except DatasetCreateFailedError:
|
||||
logger.exception("Dataset creation failed")
|
||||
await ctx.error("Dataset creation failed")
|
||||
return DatasetError.create(
|
||||
error="Dataset creation failed", error_type="CreateFailedError"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error in create_dataset")
|
||||
await ctx.error("Unexpected error: %s" % (type(exc).__name__,))
|
||||
raise
|
||||
@@ -21,6 +21,7 @@ from typing import Any, TYPE_CHECKING
|
||||
from urllib import parse
|
||||
|
||||
import sqlalchemy as sqla
|
||||
from flask import current_app
|
||||
from flask_appbuilder import Model
|
||||
from flask_appbuilder.models.decorators import renders
|
||||
from markupsafe import escape, Markup
|
||||
@@ -225,6 +226,11 @@ class Slice( # pylint: disable=too-many-public-methods
|
||||
"query_context": self.query_context,
|
||||
"modified": self.modified(),
|
||||
"owners": [owner.id for owner in self.owners],
|
||||
"extra_owners": (
|
||||
[u["id"] for u in resolver(self)]
|
||||
if (resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"))
|
||||
else []
|
||||
),
|
||||
"slice_id": self.id,
|
||||
"slice_name": self.slice_name,
|
||||
"slice_url": self.slice_url,
|
||||
|
||||
@@ -3153,6 +3153,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
:raises SupersetSecurityException: If the user cannot access the resource
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from flask import current_app
|
||||
|
||||
from superset import is_feature_enabled
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.dashboard import Dashboard
|
||||
@@ -3160,6 +3162,22 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.utils.core import shortid
|
||||
|
||||
# Extension hook: bypass all permission checks if an external system
|
||||
# (e.g. folder permissions) grants access to this resource.
|
||||
if bypass := current_app.config.get("EXTRA_RAISE_FOR_ACCESS_BYPASS"):
|
||||
if bypass(
|
||||
user_id=get_user_id(),
|
||||
dashboard=dashboard,
|
||||
chart=chart,
|
||||
datasource=datasource,
|
||||
query_context=query_context,
|
||||
):
|
||||
logger.info(
|
||||
"EXTRA_RAISE_FOR_ACCESS_BYPASS granted access for user %s",
|
||||
get_user_id(),
|
||||
)
|
||||
return
|
||||
|
||||
if sql and database:
|
||||
query = Query(
|
||||
database=database,
|
||||
@@ -4147,6 +4165,17 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
owners = orig_resource.owners if hasattr(orig_resource, "owners") else []
|
||||
|
||||
if g.user.is_anonymous or g.user not in owners:
|
||||
# Extension hook: check if the user is an extra owner
|
||||
resolver = current_app.config.get("EXTRA_OWNERS_RESOLVER")
|
||||
if resolver and not g.user.is_anonymous:
|
||||
extra_owners = resolver(orig_resource)
|
||||
user_id = g.user.id
|
||||
if any(
|
||||
(u.id if hasattr(u, "id") else u.get("id")) == user_id
|
||||
for u in extra_owners
|
||||
):
|
||||
return
|
||||
|
||||
raise SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
|
||||
|
||||
585
tests/unit_tests/mcp_service/dataset/tool/test_create_dataset.py
Normal file
585
tests/unit_tests/mcp_service/dataset/tool/test_create_dataset.py
Normal file
@@ -0,0 +1,585 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for create_dataset MCP tool."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from superset.commands.dataset.exceptions import (
|
||||
DatabaseNotFoundValidationError,
|
||||
DatasetCreateFailedError,
|
||||
DatasetDataAccessIsNotAllowed,
|
||||
DatasetExistsValidationError,
|
||||
DatasetInvalidError,
|
||||
TableNotFoundValidationError,
|
||||
)
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils import json
|
||||
|
||||
# Use importlib to get the module object directly, bypassing the __init__.py
|
||||
# attribute binding that shadows the module name with the exported function.
|
||||
# This ensures patch.object resolves to the module in all Python versions.
|
||||
create_dataset_module = importlib.import_module(
|
||||
"superset.mcp_service.dataset.tool.create_dataset"
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_dataset(
|
||||
dataset_id: int = 42,
|
||||
table_name: str = "orders",
|
||||
schema: str = "public",
|
||||
database_name: str = "main_db",
|
||||
) -> MagicMock:
|
||||
dataset = MagicMock()
|
||||
dataset.id = dataset_id
|
||||
dataset.table_name = table_name
|
||||
dataset.schema = schema
|
||||
dataset.description = None
|
||||
dataset.certified_by = None
|
||||
dataset.certification_details = None
|
||||
dataset.changed_by_name = "admin"
|
||||
dataset.changed_on = None
|
||||
dataset.changed_on_humanized = None
|
||||
dataset.created_by_name = "admin"
|
||||
dataset.created_on = None
|
||||
dataset.created_on_humanized = None
|
||||
dataset.tags = []
|
||||
dataset.owners = []
|
||||
dataset.is_virtual = False
|
||||
dataset.is_favorite = None
|
||||
dataset.database_id = 1
|
||||
dataset.schema_perm = f"[{database_name}].[{schema}]"
|
||||
dataset.url = f"/tablemodelview/edit/{dataset_id}"
|
||||
dataset.database = MagicMock()
|
||||
dataset.database.database_name = database_name
|
||||
dataset.sql = None
|
||||
dataset.main_dttm_col = None
|
||||
dataset.offset = 0
|
||||
dataset.cache_timeout = 0
|
||||
dataset.params = {}
|
||||
dataset.template_params = {}
|
||||
dataset.extra = {}
|
||||
dataset.uuid = f"dataset-uuid-{dataset_id}"
|
||||
dataset.columns = []
|
||||
dataset.metrics = []
|
||||
return dataset
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server():
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_auth():
|
||||
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
|
||||
mock_user = Mock()
|
||||
mock_user.id = 1
|
||||
mock_user.username = "admin"
|
||||
mock_get_user.return_value = mock_user
|
||||
yield mock_get_user
|
||||
|
||||
|
||||
class TestCreateDataset:
|
||||
"""Tests for the create_dataset MCP tool."""
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_success(self, mock_command_class, mcp_server) -> None:
|
||||
"""Happy path: tool creates dataset and returns DatasetInfo."""
|
||||
mock_dataset = _make_mock_dataset()
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.return_value = mock_dataset
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert result.content is not None
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["id"] == 42
|
||||
assert data["table_name"] == "orders"
|
||||
assert data["schema"] == "public"
|
||||
|
||||
# Verify the command was called with the right properties
|
||||
call_kwargs = mock_command_class.call_args[0][0]
|
||||
assert call_kwargs["database"] == 1
|
||||
assert call_kwargs["schema"] == "public"
|
||||
assert call_kwargs["table_name"] == "orders"
|
||||
assert "owners" not in call_kwargs
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_with_owners(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Owners list is forwarded to the command when supplied."""
|
||||
mock_dataset = _make_mock_dataset()
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.return_value = mock_dataset
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 2,
|
||||
"schema": "sales",
|
||||
"table_name": "transactions",
|
||||
"owners": [5, 10],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["id"] == 42
|
||||
|
||||
call_kwargs = mock_command_class.call_args[0][0]
|
||||
assert call_kwargs["owners"] == [5, 10]
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_already_exists(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Returns DatasetExistsError when the table is already registered.
|
||||
|
||||
CreateDatasetCommand.validate() wraps DatasetExistsValidationError inside
|
||||
DatasetInvalidError. The tool must inspect get_list_classnames() to surface
|
||||
the typed error response.
|
||||
"""
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.side_effect = DatasetInvalidError(
|
||||
exceptions=[DatasetExistsValidationError(Table("orders", "public", None))]
|
||||
)
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "DatasetExistsError"
|
||||
assert "error" in data
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_table_not_found(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Returns TableNotFoundError when the physical table does not exist in the DB.
|
||||
|
||||
CreateDatasetCommand.validate() wraps TableNotFoundValidationError inside
|
||||
DatasetInvalidError. The tool must inspect get_list_classnames() to surface
|
||||
the typed error response.
|
||||
"""
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.side_effect = DatasetInvalidError(
|
||||
exceptions=[
|
||||
TableNotFoundValidationError(Table("missing_table", "public", None))
|
||||
]
|
||||
)
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": "missing_table",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "TableNotFoundError"
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_with_catalog(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Catalog field is normalized and forwarded to the command when supplied."""
|
||||
mock_dataset = _make_mock_dataset()
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.return_value = mock_dataset
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"catalog": " hive ",
|
||||
"schema": "default",
|
||||
"table_name": "events",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
call_kwargs = mock_command_class.call_args[0][0]
|
||||
assert call_kwargs["catalog"] == "hive"
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_invalid_error(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""DatasetInvalidError is returned as ValidationError type."""
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.side_effect = DatasetInvalidError()
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "ValidationError"
|
||||
assert "error" in data
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_create_failed_error(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""DatasetCreateFailedError is returned as CreateFailedError type."""
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.side_effect = DatasetCreateFailedError()
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "CreateFailedError"
|
||||
assert "Dataset creation failed" in data["error"]
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_database_not_found(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Returns DatabaseNotFoundError when CreateDatasetCommand raises it."""
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.side_effect = DatasetInvalidError(
|
||||
exceptions=[DatabaseNotFoundValidationError()]
|
||||
)
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 999,
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "DatabaseNotFoundError"
|
||||
assert "Database not found" in data["error"]
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_access_denied(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Returns AccessDeniedError when CreateDatasetCommand raises it."""
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.side_effect = DatasetInvalidError(
|
||||
exceptions=[DatasetDataAccessIsNotAllowed("Access is Denied")]
|
||||
)
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "secret",
|
||||
"table_name": "restricted_table",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "AccessDeniedError"
|
||||
assert "Access denied" in data["error"]
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_unexpected_error(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Unexpected exceptions are re-raised as ToolError (handled by middleware)."""
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.side_effect = RuntimeError("DB connection lost")
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_missing_required_fields(self, mcp_server) -> None:
|
||||
"""Missing required fields raise a validation error before the tool runs."""
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
# database_id and table_name are omitted intentionally
|
||||
"schema": "public",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_returns_full_dataset_info(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""The returned DatasetInfo includes columns, metrics, and all core fields."""
|
||||
mock_dataset = _make_mock_dataset(
|
||||
dataset_id=99, table_name="sales", schema="dw"
|
||||
)
|
||||
|
||||
col = MagicMock()
|
||||
col.column_name = "amount"
|
||||
col.verbose_name = "Amount"
|
||||
col.type = "NUMERIC"
|
||||
col.is_dttm = False
|
||||
col.groupby = True
|
||||
col.filterable = True
|
||||
col.description = "Sale amount"
|
||||
mock_dataset.columns = [col]
|
||||
|
||||
metric = MagicMock()
|
||||
metric.metric_name = "total_sales"
|
||||
metric.verbose_name = "Total Sales"
|
||||
metric.expression = "SUM(amount)"
|
||||
metric.description = "Sum of amounts"
|
||||
metric.d3format = None
|
||||
mock_dataset.metrics = [metric]
|
||||
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.return_value = mock_dataset
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "dw",
|
||||
"table_name": "sales",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["id"] == 99
|
||||
assert data["table_name"] == "sales"
|
||||
assert data["schema"] == "dw"
|
||||
assert data["is_virtual"] is False
|
||||
assert len(data["columns"]) == 1
|
||||
assert data["columns"][0]["column_name"] == "amount"
|
||||
assert len(data["metrics"]) == 1
|
||||
assert data["metrics"][0]["metric_name"] == "total_sales"
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_table_name_whitespace_normalized(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Whitespace in table_name is stripped before forwarding to the command."""
|
||||
mock_dataset = _make_mock_dataset()
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.return_value = mock_dataset
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": " orders ",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
call_kwargs = mock_command_class.call_args[0][0]
|
||||
assert call_kwargs["table_name"] == "orders"
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_blank_schema_normalized_to_none(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Blank schema string is treated as absent: not forwarded to the command."""
|
||||
mock_dataset = _make_mock_dataset(schema="")
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.return_value = mock_dataset
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "",
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
call_kwargs = mock_command_class.call_args[0][0]
|
||||
assert "schema" not in call_kwargs
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_blank_catalog_normalized_to_none(
|
||||
self, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Blank catalog string is treated as absent: not forwarded to the command."""
|
||||
mock_dataset = _make_mock_dataset()
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.return_value = mock_dataset
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"catalog": "",
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
call_kwargs = mock_command_class.call_args[0][0]
|
||||
assert "catalog" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_non_string_namespace_rejected(
|
||||
self, mcp_server
|
||||
) -> None:
|
||||
"""Non-string schema/catalog values fail validation, not silently dropped."""
|
||||
async with Client(mcp_server) as client:
|
||||
for field, value in (("schema", 123), ("catalog", {"name": "hive"})):
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"table_name": "orders",
|
||||
field: value,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@patch.object(create_dataset_module, "CreateDatasetCommand")
|
||||
@patch.object(create_dataset_module, "serialize_dataset_object", return_value=None)
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_dataset_when_serialize_returns_none(
|
||||
self, mock_serialize, mock_command_class, mcp_server
|
||||
) -> None:
|
||||
"""Returns SerializationError when serialize_dataset_object returns None."""
|
||||
mock_dataset = _make_mock_dataset()
|
||||
mock_command = MagicMock()
|
||||
mock_command.run.return_value = mock_dataset
|
||||
mock_command_class.return_value = mock_command
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"create_dataset",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"table_name": "orders",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "SerializationError"
|
||||
204
tests/unit_tests/security/test_permission_hooks.py
Normal file
204
tests/unit_tests/security/test_permission_hooks.py
Normal file
@@ -0,0 +1,204 @@
|
||||
# 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.
|
||||
"""Tests for config-based permission extension hooks.
|
||||
|
||||
Follows the same pattern as EXTRA_DYNAMIC_QUERY_FILTERS tests in
|
||||
tests/unit_tests/databases/api_test.py.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from superset import db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_chart(app_context: None):
|
||||
"""Create a minimal chart for testing."""
|
||||
from superset.models.slice import Slice
|
||||
|
||||
chart = Slice(
|
||||
slice_name="test_permission_hooks_chart",
|
||||
datasource_type="table",
|
||||
viz_type="table",
|
||||
params="{}",
|
||||
)
|
||||
db.session.add(chart)
|
||||
db.session.flush()
|
||||
yield chart
|
||||
db.session.delete(chart)
|
||||
db.session.flush()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user(app_context: None):
|
||||
"""Create a minimal user for testing."""
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
user = User(
|
||||
first_name="test",
|
||||
last_name="hooks",
|
||||
username="test_permission_hooks_user",
|
||||
email="test_hooks@example.com",
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
yield user
|
||||
db.session.delete(user)
|
||||
db.session.flush()
|
||||
|
||||
|
||||
def test_extra_owners_resolver_injects_into_extra_owners(sample_chart, monkeypatch):
|
||||
"""EXTRA_OWNERS_RESOLVER populates Slice.data['extra_owners'], not 'owners'."""
|
||||
from flask import current_app
|
||||
|
||||
original_owner_ids = [o.id for o in sample_chart.owners]
|
||||
|
||||
# Without config — extra_owners is empty, owners unchanged
|
||||
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", None)
|
||||
data = sample_chart.data
|
||||
assert data["owners"] == original_owner_ids
|
||||
assert data["extra_owners"] == []
|
||||
|
||||
# With config — extra_owners populated, owners unchanged
|
||||
def _resolver(resource):
|
||||
return [{"id": 99999, "first_name": "Folder", "last_name": "Editor"}]
|
||||
|
||||
resolver_mock = Mock(side_effect=_resolver)
|
||||
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", resolver_mock)
|
||||
|
||||
data = sample_chart.data
|
||||
assert data["owners"] == original_owner_ids
|
||||
assert 99999 in data["extra_owners"]
|
||||
assert resolver_mock.call_count == 1
|
||||
|
||||
|
||||
def test_extra_owners_resolver_empty_returns_unchanged(sample_chart, monkeypatch):
|
||||
"""EXTRA_OWNERS_RESOLVER returning empty list leaves extra_owners empty."""
|
||||
from flask import current_app
|
||||
|
||||
resolver_mock = Mock(return_value=[])
|
||||
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", resolver_mock)
|
||||
|
||||
data = sample_chart.data
|
||||
original_owner_ids = [o.id for o in sample_chart.owners]
|
||||
assert data["owners"] == original_owner_ids
|
||||
assert data["extra_owners"] == []
|
||||
assert resolver_mock.call_count == 1
|
||||
|
||||
|
||||
def test_raise_for_access_bypass_skips_checks(app_context: None, monkeypatch):
|
||||
"""EXTRA_RAISE_FOR_ACCESS_BYPASS returning True skips all permission checks."""
|
||||
from flask import current_app
|
||||
|
||||
from superset import security_manager
|
||||
|
||||
bypass_mock = Mock(return_value=True)
|
||||
monkeypatch.setitem(
|
||||
current_app.config, "EXTRA_RAISE_FOR_ACCESS_BYPASS", bypass_mock
|
||||
)
|
||||
|
||||
security_manager.raise_for_access(dashboard=None, chart=None)
|
||||
assert bypass_mock.call_count == 1
|
||||
|
||||
|
||||
def test_raise_for_access_no_bypass_without_config(app_context: None, monkeypatch):
|
||||
"""Without EXTRA_RAISE_FOR_ACCESS_BYPASS, normal checks proceed."""
|
||||
from flask import current_app
|
||||
|
||||
from superset import security_manager
|
||||
|
||||
monkeypatch.setitem(current_app.config, "EXTRA_RAISE_FOR_ACCESS_BYPASS", None)
|
||||
security_manager.raise_for_access(dashboard=None, chart=None)
|
||||
|
||||
|
||||
def test_ownership_check_allows_non_owner(sample_chart, sample_user, monkeypatch):
|
||||
"""EXTRA_OWNERS_RESOLVER returning the user allows a non-owner to pass."""
|
||||
from flask import current_app, g
|
||||
|
||||
from superset import security_manager
|
||||
|
||||
resolver_mock = Mock(return_value=[sample_user])
|
||||
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", resolver_mock)
|
||||
monkeypatch.setattr(g, "user", sample_user, raising=False)
|
||||
|
||||
security_manager.raise_for_ownership(sample_chart)
|
||||
resolver_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_owner_auto_add_skipped_with_resolver(
|
||||
sample_user, app_context: None, monkeypatch
|
||||
):
|
||||
"""When EXTRA_OWNERS_RESOLVER is set, current user is NOT auto-added."""
|
||||
from flask import current_app, g
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset.commands.utils import populate_owner_list
|
||||
|
||||
other_user = User(
|
||||
first_name="other",
|
||||
last_name="skip",
|
||||
username="test_skip_other",
|
||||
email="skip_other@example.com",
|
||||
)
|
||||
db.session.add(other_user)
|
||||
db.session.flush()
|
||||
|
||||
resolver = Mock(return_value=[])
|
||||
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", resolver)
|
||||
monkeypatch.setattr(g, "user", sample_user, raising=False)
|
||||
|
||||
try:
|
||||
owners = populate_owner_list([other_user.id], default_to_user=False)
|
||||
owner_ids = [o.id for o in owners]
|
||||
# Only the explicitly passed owner — current user NOT auto-added
|
||||
assert owner_ids == [other_user.id]
|
||||
assert sample_user.id not in owner_ids
|
||||
finally:
|
||||
db.session.delete(other_user)
|
||||
db.session.flush()
|
||||
|
||||
|
||||
def test_owner_auto_add_without_resolver(sample_user, app_context: None, monkeypatch):
|
||||
"""Without EXTRA_OWNERS_RESOLVER, current user IS auto-added."""
|
||||
from flask import current_app, g
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset.commands.utils import populate_owner_list
|
||||
|
||||
other_user = User(
|
||||
first_name="other",
|
||||
last_name="user",
|
||||
username="test_other_user",
|
||||
email="other@example.com",
|
||||
)
|
||||
db.session.add(other_user)
|
||||
db.session.flush()
|
||||
|
||||
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", None)
|
||||
monkeypatch.setattr(g, "user", sample_user, raising=False)
|
||||
|
||||
try:
|
||||
owners = populate_owner_list([other_user.id], default_to_user=False)
|
||||
owner_ids = [o.id for o in owners]
|
||||
# Both the passed owner AND current user auto-added
|
||||
assert sample_user.id in owner_ids
|
||||
assert other_user.id in owner_ids
|
||||
finally:
|
||||
db.session.delete(other_user)
|
||||
db.session.flush()
|
||||
Reference in New Issue
Block a user