mirror of
https://github.com/apache/superset.git
synced 2026-06-12 02:59:27 +00:00
Compare commits
4 Commits
mcp-duplic
...
fix/smtp-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6fe3a76d | ||
|
|
42d6e5217a | ||
|
|
4280db194c | ||
|
|
45f0743e1a |
12
UPDATING.md
12
UPDATING.md
@@ -70,6 +70,18 @@ superset revoke-guest-tokens
|
||||
|
||||
This change is backward compatible. The feature is off by default, and even when enabled nothing is revoked until an admin explicitly bumps the version: the expected version starts at `0`, and tokens minted before this change (which carry no version claim) are treated as version `0`. No database migration is required.
|
||||
|
||||
### SMTP server certificate validation enabled by default
|
||||
|
||||
`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box.
|
||||
|
||||
If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`:
|
||||
|
||||
```python
|
||||
SMTP_SSL_SERVER_AUTH = False
|
||||
```
|
||||
|
||||
The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation.
|
||||
|
||||
### Dataset import validates catalog against the target connection
|
||||
|
||||
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
|
||||
|
||||
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"holidays>=0.45, <1",
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"jsonpath-ng>=1.6.1, <2",
|
||||
"Mako>=1.2.2",
|
||||
"markdown>=3.10.2",
|
||||
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
|
||||
@@ -94,7 +94,7 @@ dependencies = [
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <6.0",
|
||||
"rison>=2.0.0, <3.0",
|
||||
"selenium>=4.44.0, <5.0",
|
||||
"selenium>=4.14.0, <5.0",
|
||||
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=3.15.0",
|
||||
@@ -107,7 +107,7 @@ dependencies = [
|
||||
"typing-extensions>=4, <5",
|
||||
"waitress; sys_platform == 'win32'",
|
||||
"watchdog>=6.0.0",
|
||||
"wtforms>=3.2.2, <4",
|
||||
"wtforms>=2.3.3, <4",
|
||||
"wtforms-json",
|
||||
"xlsxwriter>=3.2.9, <3.3",
|
||||
]
|
||||
@@ -121,7 +121,7 @@ bigquery = [
|
||||
"sqlalchemy-bigquery>=1.15.0",
|
||||
"google-cloud-bigquery>=3.10.0",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.1.1, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=0.13.0, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
|
||||
d1 = [
|
||||
@@ -161,7 +161,7 @@ hive = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
"tableschema",
|
||||
"thrift>=0.23.0, <1.0.0",
|
||||
"thrift>=0.14.1, <1.0.0",
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
@@ -195,7 +195,7 @@ spark = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7",
|
||||
"tableschema",
|
||||
"thrift>=0.23.0, <1",
|
||||
"thrift>=0.14.1, <1",
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.7.21",
|
||||
|
||||
@@ -50,7 +50,7 @@ cattrs==25.1.1
|
||||
# via requests-cache
|
||||
celery==5.5.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
certifi==2026.5.20
|
||||
certifi==2025.6.15
|
||||
# via
|
||||
# requests
|
||||
# selenium
|
||||
@@ -194,7 +194,7 @@ jinja2==3.1.6
|
||||
# via
|
||||
# flask
|
||||
# flask-babel
|
||||
jsonpath-ng==1.8.0
|
||||
jsonpath-ng==1.7.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
jsonschema==4.23.0
|
||||
# via
|
||||
@@ -286,6 +286,8 @@ pillow==12.2.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
platformdirs==4.3.8
|
||||
# via requests-cache
|
||||
ply==3.11
|
||||
# via jsonpath-ng
|
||||
polyline==2.0.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
prison==0.2.1
|
||||
@@ -378,7 +380,7 @@ rpds-py==0.25.0
|
||||
# referencing
|
||||
rsa==4.9.1
|
||||
# via google-auth
|
||||
selenium==4.44.0
|
||||
selenium==4.32.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
setuptools==80.9.0
|
||||
# via -r requirements/base.in
|
||||
@@ -421,7 +423,7 @@ sshtunnel==0.4.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
tabulate==0.10.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
trio==0.33.0
|
||||
trio==0.30.0
|
||||
# via
|
||||
# selenium
|
||||
# trio-websocket
|
||||
@@ -478,7 +480,7 @@ wrapt==1.17.2
|
||||
# via deprecated
|
||||
wsproto==1.2.0
|
||||
# via trio-websocket
|
||||
wtforms==3.2.2
|
||||
wtforms==3.2.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -112,7 +112,7 @@ celery==5.5.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
certifi==2026.5.20
|
||||
certifi==2025.6.15
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# httpcore
|
||||
@@ -471,7 +471,7 @@ jmespath==1.1.0
|
||||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
jsonpath-ng==1.8.0
|
||||
jsonpath-ng==1.7.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -674,6 +674,10 @@ platformdirs==4.3.8
|
||||
# virtualenv
|
||||
pluggy==1.5.0
|
||||
# via pytest
|
||||
ply==3.11
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# jsonpath-ng
|
||||
polib==1.2.0
|
||||
# via apache-superset
|
||||
polyline==2.0.2
|
||||
@@ -921,7 +925,7 @@ s3transfer==0.16.0
|
||||
# via boto3
|
||||
secretstorage==3.5.0
|
||||
# via keyring
|
||||
selenium==4.44.0
|
||||
selenium==4.32.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -1019,7 +1023,7 @@ tqdm==4.67.1
|
||||
# prophet
|
||||
trino==0.330.0
|
||||
# via apache-superset
|
||||
trio==0.33.0
|
||||
trio==0.30.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
@@ -1121,7 +1125,7 @@ wsproto==1.2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# trio-websocket
|
||||
wtforms==3.2.2
|
||||
wtforms==3.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -48,7 +48,6 @@ module.exports = {
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'@babel/plugin-transform-export-namespace-from',
|
||||
['@babel/plugin-transform-class-properties', { loose: true }],
|
||||
'@babel/plugin-transform-class-static-block',
|
||||
['@babel/plugin-transform-optional-chaining', { loose: true }],
|
||||
['@babel/plugin-transform-private-methods', { loose: true }],
|
||||
['@babel/plugin-transform-nullish-coalescing-operator', { loose: true }],
|
||||
|
||||
234
superset-frontend/package-lock.json
generated
234
superset-frontend/package-lock.json
generated
@@ -95,7 +95,7 @@
|
||||
"echarts": "^5.6.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.4.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -178,13 +178,13 @@
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.13",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.4.2",
|
||||
"@storybook/addon-links": "10.4.2",
|
||||
"@storybook/react-webpack5": "10.4.2",
|
||||
"@storybook/addon-docs": "10.4.1",
|
||||
"@storybook/addon-links": "10.4.1",
|
||||
"@storybook/react-webpack5": "10.4.1",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
@@ -242,7 +242,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-storybook": "10.4.1",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -272,7 +272,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.2",
|
||||
"storybook": "10.4.1",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
@@ -3939,38 +3939,50 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/bigdecimal": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.5.tgz",
|
||||
"integrity": "sha512-2XTKNrZRaCUyXK2976wfutqxMBuPO/S/zbJnQdysLI2Zy5mWPVNVEkE6tsTcSVWSE7DgO88t8DtBy+uf3I8bxg==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.0.tgz",
|
||||
"integrity": "sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.2.0.tgz",
|
||||
"integrity": "sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/bigdecimal": "0.2.0",
|
||||
"@formatjs/fast-memoize": "3.1.1",
|
||||
"@formatjs/intl-localematcher": "0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/fast-memoize": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.5.tgz",
|
||||
"integrity": "sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.1.tgz",
|
||||
"integrity": "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/intl-durationformat": {
|
||||
"version": "0.10.13",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.13.tgz",
|
||||
"integrity": "sha512-A1dBcOh1YrcRf/AbmZHFVXgIYkpAaFgyGaYavO/KutbqEXY3HI63o2E1ctmxmllfg3qn3TZGtZux42EFwHNTbg==",
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.3.tgz",
|
||||
"integrity": "sha512-xRS3GaOlsQLwz0n56SvaddwEnl2NLPKBvYg2M32ak/27dodmVxFJz3j7Nqj7EwKyHTu3f/e+BeoKPrIDUSXTuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/bigdecimal": "0.2.5",
|
||||
"@formatjs/intl-localematcher": "0.8.9"
|
||||
"@formatjs/ecma402-abstract": "3.2.0",
|
||||
"@formatjs/intl-localematcher": "0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
"version": "0.8.9",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.9.tgz",
|
||||
"integrity": "sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==",
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.2.tgz",
|
||||
"integrity": "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "3.1.5"
|
||||
"@formatjs/fast-memoize": "3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promise-retry": {
|
||||
@@ -9684,16 +9696,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.2.tgz",
|
||||
"integrity": "sha512-CtW1O4xSKZPNtpWgpfp4yB/x4pj/of+3MvlEDfErSlr3Hp3QmEa2pCLaecR08H5LJqJFlt1PtG0UrIynTvgW9w==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.1.tgz",
|
||||
"integrity": "sha512-IYqUdjoZe4VO2LFZlKL/gwy7DsQSWCq6hX+zc1MBmZo04yycDASk1tte57n9pdlW3ajw9yYMF/+lVBi+xQjyvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.4.2",
|
||||
"@storybook/csf-plugin": "10.4.1",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.4.2",
|
||||
"@storybook/react-dom-shim": "10.4.1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -9704,7 +9716,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -9712,10 +9724,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.1.tgz",
|
||||
"integrity": "sha512-WdPepGBxDGOUDjYd8KxMtcf+us/2PAcnBczl77XtrnxxHNs0jWesxKkiJ9yiuGrge4BPhDeAj6rxjbBoaHxLBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unplugin": "^2.3.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "^10.4.1",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.4.2.tgz",
|
||||
"integrity": "sha512-cU8h4/m+oAr8UUwF4teZG2N1ilV+vU+98Ii/Ma+IIx9M/V7i5544UxfAz84dV5Rx2Oho6x8XH3gIvmevSyPi/Q==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.4.1.tgz",
|
||||
"integrity": "sha512-h/5D23GwMuHA55sB7XDyhByF9psF7UFmaQOn72pjNAarew5eOpue5A+jXk3AKEYokHbvgQaoz+FrvWo9GEfSKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9728,7 +9775,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -9740,13 +9787,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/builder-webpack5": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.4.2.tgz",
|
||||
"integrity": "sha512-nhmV0+nThCgy1y5742SS7c4vJrd5/1KfCXCNfsJ1v4Rkq7NIQnUhEIBwkSaY63lqH7FRHlFxIjwGS63veiCJuw==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.4.1.tgz",
|
||||
"integrity": "sha512-3Ah4jUjg8nEms/5JV6odtQj9+pQ1DT/04s/V6dZKThGdl85YTrYUZV5OTgbNxYbmQn/TwpWWjQlcW8ulpo2WBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.4.2",
|
||||
"@storybook/core-webpack": "10.4.1",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"cjs-module-lexer": "^1.2.3",
|
||||
"css-loader": "^7.1.2",
|
||||
@@ -9767,7 +9814,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -9776,9 +9823,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/core-webpack": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.2.tgz",
|
||||
"integrity": "sha512-qnYKMruU8lvI4yaq2PA9Gmxjrc7EZ3DRBI/cVKwEgOIREoxzr1F1IE7t7+325k9Phylue7E5rD3A7yjxeEKUyw==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.1.tgz",
|
||||
"integrity": "sha512-Wert/4ou5WRl8WYWWS8bBW7Lxa/ASMEuQ3EVuG3SITAtPNvKDKqTFBjZLx9eJSefkX6fJ3yG85FFUOPsv6GemQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9789,42 +9836,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.2.tgz",
|
||||
"integrity": "sha512-GqX/2DeF3/jKs5D7gpDiuT9gd0c/f2TKcnQ5av4/s3YqeN+0nhm7btkCrDfgF16uzE1Zj3OrkxvB3AOkfxWgDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unplugin": "^2.3.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "^10.4.2",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
"storybook": "^10.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/global": {
|
||||
@@ -9846,13 +9858,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/preset-react-webpack": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.4.2.tgz",
|
||||
"integrity": "sha512-21ld380f0/jTTitkfhTKgP3FBnVAgMu1P1ymrRyiFYJVSJBA5YejndFFBo0ugq9iGGsHXrVdOphC/OJKbTSWRQ==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.4.1.tgz",
|
||||
"integrity": "sha512-uAR/C/oDZYhReaYpD4Rd5S4VWcXP2XO8+BwXwanKt4UHbYfOw7AQgBTeZ/6Wns/0xIXhOoA1rxO5TA2wDLUjLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.4.2",
|
||||
"@storybook/core-webpack": "10.4.1",
|
||||
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -9869,7 +9881,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -9878,14 +9890,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.2.tgz",
|
||||
"integrity": "sha512-NfEH3CrdCAgUV4Z7SPN3Iw6nofcueqtRj8iHuo77GNjz0qSfuVi9iS7a8o7x7QFSeIBZwS0Jv3CgmhN8qvoLjg==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.1.tgz",
|
||||
"integrity": "sha512-WuYz4NaUk4gmFAMliSpCbV8w6jP5OY9juBfw1huwzu2S/k5FhnVXwmrUaL0fmf3Bq/7NgkzmBBbZr6I6LuHayQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.4.2",
|
||||
"@storybook/react-dom-shim": "10.4.1",
|
||||
"react-docgen": "^8.0.2",
|
||||
"react-docgen-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -9898,7 +9910,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2",
|
||||
"storybook": "^10.4.1",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -9978,9 +9990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.2.tgz",
|
||||
"integrity": "sha512-Eng3Yt2NCjPX94QcfyLeUFhrMj0hec2yU9J/qafBVbfj9XrFI8o+0ZwYJ7uXb9ECbvPN4y06dgt/2W/LiR417w==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.1.tgz",
|
||||
"integrity": "sha512-6QFqfDNH4DMrt7yHKRfpqRopsVUc/Az+sXIdJ39IetYnHUxL3nW4NVaPc6uy/8Qi8urzUyEXL/nn7cpSIP2aPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -9992,7 +10004,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10004,15 +10016,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.4.2.tgz",
|
||||
"integrity": "sha512-x7xwGLxU0w6/qi29/cHhua8qiCvfE05ku4pPLTXF8TsP/zfGsY8tbdlKO2+YKp+iBG8vafVc//ZXOAty1oypDA==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.4.1.tgz",
|
||||
"integrity": "sha512-2jF231DrEk70I8+wVakCnKtpweGFNfxdaov883Rve0TFvhxZs42Y9PpKzSf4rusvSrWc9jdWuJ2k7ERbS50MLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-webpack5": "10.4.2",
|
||||
"@storybook/preset-react-webpack": "10.4.2",
|
||||
"@storybook/react": "10.4.2"
|
||||
"@storybook/builder-webpack5": "10.4.1",
|
||||
"@storybook/preset-react-webpack": "10.4.1",
|
||||
"@storybook/react": "10.4.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -10021,7 +10033,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.2",
|
||||
"storybook": "^10.4.1",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -19409,9 +19421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-storybook": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.4.2.tgz",
|
||||
"integrity": "sha512-l3/vzLRmb8VSi3X1Bo6/Pa+64naw1jFsZE5jPPA4izvVdNhH1rF4rGuOC3kDTU926qKVBQtKua8D24XWQtvcGg==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.4.1.tgz",
|
||||
"integrity": "sha512-sLEvd/7lg/LtXwMjj3iFxZtoeAC/8l1Qhuw3Noa8iF8i0UIgAejUs7k6DNSqHkwrPR8caWT4+3fxdMXs1iGLTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -19419,7 +19431,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8",
|
||||
"storybook": "^10.4.2"
|
||||
"storybook": "^10.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library": {
|
||||
@@ -20921,9 +20933,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.1.tgz",
|
||||
"integrity": "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw==",
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.0.tgz",
|
||||
"integrity": "sha512-3UqmoSFwzX1sNB1YSk+Co0EdH29XCW2p9g48OAiy93cjKqzuABsqw2VIgSN3CmsT/wo6pIJ3F0Jxeiiby8rhIQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -39036,9 +39048,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.2.tgz",
|
||||
"integrity": "sha512-5Ax5vbHxFgMBGGhQDm75Rrumm/HZC4ICFhMcJaM0UlqnC/4FKj/IaZtImZFupknyiiyUEcWHPQFA2kX3/VSv1A==",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.1.tgz",
|
||||
"integrity": "sha512-V1Zd2e+gBFufqAQVZ1JR8KLqALsEZ3JYSBnWwQbKa6zCfWWanR6AFMyuOkLt2gZOgGp3h2Riuz88pGNVTQSG0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -45363,7 +45375,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "^9.3.3",
|
||||
"@deck.gl/mapbox": "~9.3.2",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
@@ -45411,9 +45423,9 @@
|
||||
}
|
||||
},
|
||||
"plugins/preset-chart-deckgl/node_modules/@deck.gl/mapbox": {
|
||||
"version": "9.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.3.tgz",
|
||||
"integrity": "sha512-aUPqrwF6wkx+EtvKA3SaiK+UROMnZSmgEJWZ1qSKFSiH//kPuo5imbtXyan8sGhOet7NjnfEwJqFA3EBk7zDLA==",
|
||||
"version": "9.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.2.tgz",
|
||||
"integrity": "sha512-+T9pJwsOXwjUxyGN6oiBMfIs28VtDIG1V1Rqz4qqn4TjjNEFFw+xO0olJIg8FO5IAqw2OtePdsrMj0tX8tHdGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0"
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
"echarts": "^5.6.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.5",
|
||||
"fuse.js": "^7.4.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -261,13 +261,13 @@
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.13",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.4.2",
|
||||
"@storybook/addon-links": "10.4.2",
|
||||
"@storybook/react-webpack5": "10.4.2",
|
||||
"@storybook/addon-docs": "10.4.1",
|
||||
"@storybook/addon-links": "10.4.1",
|
||||
"@storybook/react-webpack5": "10.4.1",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
@@ -325,7 +325,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-storybook": "10.4.1",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -355,7 +355,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.2",
|
||||
"storybook": "10.4.1",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Disposable, Event } from '../common';
|
||||
import { Disposable } from '../common';
|
||||
|
||||
/**
|
||||
* Represents a menu item that links a view to a command.
|
||||
@@ -102,37 +102,3 @@ export declare function registerMenuItem(
|
||||
* ```
|
||||
*/
|
||||
export declare function getMenu(location: string): Menu | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is registered.
|
||||
*/
|
||||
export interface MenuItemRegisteredEvent {
|
||||
/** The menu item that was registered. */
|
||||
item: MenuItem;
|
||||
/** The location where the item was registered. */
|
||||
location: string;
|
||||
/** The group the item was placed in. */
|
||||
group: 'primary' | 'secondary' | 'context';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is unregistered.
|
||||
*/
|
||||
export interface MenuItemUnregisteredEvent {
|
||||
/** The menu item that was unregistered. */
|
||||
item: MenuItem;
|
||||
/** The location where the item was registered. */
|
||||
location: string;
|
||||
/** The group the item was placed in. */
|
||||
group: 'primary' | 'secondary' | 'context';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is registered.
|
||||
*/
|
||||
export declare const onDidRegisterMenuItem: Event<MenuItemRegisteredEvent>;
|
||||
|
||||
/**
|
||||
* Event fired when a menu item is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterMenuItem: Event<MenuItemUnregisteredEvent>;
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { Disposable, Event } from '../common';
|
||||
import { Disposable } from '../common';
|
||||
|
||||
/**
|
||||
* Represents a contributed view in the application.
|
||||
@@ -88,33 +88,3 @@ export declare function registerView(
|
||||
* ```
|
||||
*/
|
||||
export declare function getViews(location: string): View[] | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a view is registered.
|
||||
*/
|
||||
export interface ViewRegisteredEvent {
|
||||
/** The descriptor of the view that was registered. */
|
||||
view: View;
|
||||
/** The location where the view was registered. */
|
||||
location: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a view is unregistered.
|
||||
*/
|
||||
export interface ViewUnregisteredEvent {
|
||||
/** The descriptor of the view that was unregistered. */
|
||||
view: View;
|
||||
/** The location where the view was registered. */
|
||||
location: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a view is registered.
|
||||
*/
|
||||
export declare const onDidRegisterView: Event<ViewRegisteredEvent>;
|
||||
|
||||
/**
|
||||
* Event fired when a view is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterView: Event<ViewUnregisteredEvent>;
|
||||
|
||||
@@ -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.7",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
|
||||
@@ -35,7 +35,7 @@ test('format milliseconds in human readable format with default options', () =>
|
||||
});
|
||||
test('format seconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter({ multiplier: 1000 });
|
||||
expect(formatter(-0.5)).toBe('0s');
|
||||
expect(formatter(-0.5)).toBe('-0s');
|
||||
expect(formatter(0.5)).toBe('0s');
|
||||
expect(formatter(1)).toBe('1s');
|
||||
expect(formatter(30)).toBe('30s');
|
||||
|
||||
@@ -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.7",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "~9.3.3",
|
||||
"@deck.gl/mapbox": "~9.3.2",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
import {
|
||||
DataMaskStateWithId,
|
||||
ExtraFormData,
|
||||
Filter,
|
||||
NativeFiltersState,
|
||||
NativeFilterType,
|
||||
} from '@superset-ui/core';
|
||||
@@ -459,25 +458,6 @@ export const mockQueryDataForCountries = [
|
||||
{ country_name: 'Zimbabwe', 'SUM(SP_POP_TOTL)': 509866860 },
|
||||
];
|
||||
|
||||
export const createSelectNativeFilter = (
|
||||
id: string,
|
||||
name: string,
|
||||
column: string = name,
|
||||
): Filter => ({
|
||||
id,
|
||||
name,
|
||||
type: NativeFilterType.NativeFilter,
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 2, column: { name: column } }],
|
||||
defaultDataMask: { filterState: { value: null }, extraFormData: {} },
|
||||
controlValues: {},
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
description: '',
|
||||
chartsInScope: [],
|
||||
tabsInScope: [],
|
||||
});
|
||||
|
||||
export const buildNativeFilter = (
|
||||
id: string,
|
||||
name: string,
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { useComponentDidUpdate } from '@superset-ui/core';
|
||||
import { Grid } from '@superset-ui/core/components';
|
||||
import { useViews } from 'src/core';
|
||||
import { views } from 'src/core';
|
||||
import { Splitter } from 'src/components/Splitter';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth';
|
||||
@@ -96,7 +96,7 @@ const AppLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
setRightWidth(possibleRightWidth);
|
||||
}
|
||||
};
|
||||
const viewItems = useViews(ViewLocations.sqllab.rightSidebar) || [];
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.rightSidebar) || [];
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
import PanelToolbar from 'src/components/PanelToolbar';
|
||||
import { useViews } from 'src/core';
|
||||
import { views } from 'src/core';
|
||||
import { resolveView } from 'src/core/views';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
@@ -107,7 +107,7 @@ const SouthPane = ({
|
||||
const editorId = tabViewId ?? id;
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const viewItems = useViews(ViewLocations.sqllab.panels) || [];
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
|
||||
const { offline, tables } = useSelector(
|
||||
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
|
||||
offline,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Flex } from '@superset-ui/core/components';
|
||||
import ViewListExtension from 'src/components/ViewListExtension';
|
||||
import { useViews } from 'src/core';
|
||||
import { views } from 'src/core';
|
||||
import { SQL_EDITOR_STATUSBAR_HEIGHT } from 'src/SqlLab/constants';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
|
||||
@@ -38,7 +38,7 @@ const Container = styled(Flex)`
|
||||
`;
|
||||
|
||||
const StatusBar = () => {
|
||||
const statusBarViews = useViews(ViewLocations.sqllab.statusBar) || [];
|
||||
const statusBarViews = views.getViews(ViewLocations.sqllab.statusBar) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -17,12 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useMenu } from 'src/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Button, Divider, Dropdown } from '@superset-ui/core/components';
|
||||
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { commands } from 'src/core';
|
||||
import { commands, menus } from 'src/core';
|
||||
|
||||
export interface PanelToolbarProps {
|
||||
viewId: string;
|
||||
@@ -36,7 +35,7 @@ const PanelToolbar = ({
|
||||
defaultSecondaryActions,
|
||||
}: PanelToolbarProps) => {
|
||||
const theme = useTheme();
|
||||
const menu = useMenu(viewId);
|
||||
const menu = menus.getMenu(viewId);
|
||||
|
||||
const primaryItems = menu?.primary || [];
|
||||
const secondaryItems = menu?.secondary || [];
|
||||
|
||||
@@ -39,7 +39,8 @@ jest.mock('./EditorProviders', () => ({
|
||||
getInstance: () => ({
|
||||
getProvider: jest.fn().mockReturnValue(undefined),
|
||||
hasProvider: jest.fn().mockReturnValue(false),
|
||||
subscribe: jest.fn().mockReturnValue(() => {}),
|
||||
onDidRegister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
onDidUnregister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -26,12 +26,13 @@
|
||||
* back to the default Ace editor.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore, forwardRef } from 'react';
|
||||
import { useState, useEffect, forwardRef } from 'react';
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import EditorProviders from './EditorProviders';
|
||||
import AceEditorProvider from './AceEditorProvider';
|
||||
|
||||
type EditorLanguage = editors.EditorLanguage;
|
||||
type EditorProps = editors.EditorProps;
|
||||
type EditorHandle = editors.EditorHandle;
|
||||
|
||||
@@ -41,6 +42,49 @@ type EditorHandle = editors.EditorHandle;
|
||||
*/
|
||||
export type EditorHostProps = EditorProps;
|
||||
|
||||
/**
|
||||
* Hook to track editor provider changes.
|
||||
* Returns the provider for the specified language and re-renders when it changes.
|
||||
*/
|
||||
const useEditorProvider = (language: EditorLanguage) => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
const [provider, setProvider] = useState(() => manager.getProvider(language));
|
||||
|
||||
useEffect(() => {
|
||||
// Helper to safely update provider state, always fetching latest from manager
|
||||
const updateProvider = () => {
|
||||
setProvider(prev => {
|
||||
const current = manager.getProvider(language);
|
||||
return current !== prev ? current : prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Subscribe to provider changes
|
||||
const registerDisposable = manager.onDidRegister(event => {
|
||||
if (event.editor.languages.includes(language)) {
|
||||
updateProvider();
|
||||
}
|
||||
});
|
||||
|
||||
const unregisterDisposable = manager.onDidUnregister(event => {
|
||||
if (event.editor.languages.includes(language)) {
|
||||
updateProvider();
|
||||
}
|
||||
});
|
||||
|
||||
// Check for provider on mount (in case it was registered before this component mounted)
|
||||
updateProvider();
|
||||
|
||||
return () => {
|
||||
registerDisposable.dispose();
|
||||
unregisterDisposable.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [language, manager]);
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
/**
|
||||
* EditorHost component that dynamically resolves and renders the appropriate editor.
|
||||
*
|
||||
@@ -62,12 +106,7 @@ export type EditorHostProps = EditorProps;
|
||||
const EditorHost = forwardRef<EditorHandle, EditorHostProps>((props, ref) => {
|
||||
const { language } = props;
|
||||
const theme = useTheme();
|
||||
const manager = EditorProviders.getInstance();
|
||||
const provider = useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
() => manager.getProvider(language),
|
||||
() => undefined,
|
||||
);
|
||||
const provider = useEditorProvider(language);
|
||||
|
||||
// Merge theme into props
|
||||
const propsWithTheme = { ...props, theme };
|
||||
|
||||
@@ -93,17 +93,6 @@ class EditorProviders {
|
||||
*/
|
||||
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
|
||||
|
||||
private syncListeners: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* Stable-reference subscribe function for useSyncExternalStore.
|
||||
* Defined as an arrow property so the reference is bound to this instance at construction.
|
||||
*/
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
this.syncListeners.add(listener);
|
||||
return () => this.syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
@@ -156,7 +145,6 @@ class EditorProviders {
|
||||
|
||||
// Fire registration event
|
||||
this.registerEmitter.fire({ editor });
|
||||
this.syncListeners.forEach(l => l());
|
||||
|
||||
// Return disposable for cleanup
|
||||
return new Disposable(() => {
|
||||
@@ -188,7 +176,6 @@ class EditorProviders {
|
||||
|
||||
// Fire unregistration event
|
||||
this.unregisterEmitter.fire({ editor });
|
||||
this.syncListeners.forEach(l => l());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,7 +234,6 @@ class EditorProviders {
|
||||
public reset(): void {
|
||||
this.providers.clear();
|
||||
this.languageToProvider.clear();
|
||||
this.syncListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
* and resolution functions declared in the API types.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { editors as editorsApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import EditorProviders from './EditorProviders';
|
||||
@@ -110,23 +109,6 @@ export const onDidUnregisterEditor = (
|
||||
return manager.onDidUnregister(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that returns the editor provider for a specific language and re-renders when it changes.
|
||||
*
|
||||
* @param language The language to get an editor for
|
||||
* @returns The editor provider or undefined if no extension provides one
|
||||
*/
|
||||
export const useEditor = (
|
||||
language: EditorLanguage,
|
||||
): EditorProvider | undefined => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
() => manager.getProvider(language),
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Editors API object for use in the extension system.
|
||||
*/
|
||||
|
||||
@@ -24,14 +24,11 @@
|
||||
* Extensions register menu items as side effects at import time.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { menus as menusApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
|
||||
type MenuItem = menusApi.MenuItem;
|
||||
type Menu = menusApi.Menu;
|
||||
type MenuItemRegisteredEvent = menusApi.MenuItemRegisteredEvent;
|
||||
type MenuItemUnregisteredEvent = menusApi.MenuItemUnregisteredEvent;
|
||||
|
||||
type StoredMenuItem = {
|
||||
item: MenuItem;
|
||||
@@ -41,27 +38,6 @@ type StoredMenuItem = {
|
||||
|
||||
const menuItems: StoredMenuItem[] = [];
|
||||
|
||||
const syncListeners = new Set<() => void>();
|
||||
const subscribe = (listener: () => void) => {
|
||||
syncListeners.add(listener);
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
|
||||
|
||||
const menuCache = new Map<string, Menu | undefined>();
|
||||
const notifyRegister = (event: MenuItemRegisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
};
|
||||
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
};
|
||||
|
||||
const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
item: MenuItem,
|
||||
location: string,
|
||||
@@ -69,13 +45,11 @@ const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
): Disposable => {
|
||||
const stored: StoredMenuItem = { item, location, group };
|
||||
menuItems.push(stored);
|
||||
notifyRegister({ item, location, group });
|
||||
return new Disposable(() => {
|
||||
const index = menuItems.indexOf(stored);
|
||||
if (index >= 0) {
|
||||
menuItems.splice(index, 1);
|
||||
}
|
||||
notifyUnregister({ item, location, group });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -103,34 +77,7 @@ const getMenu: typeof menusApi.getMenu = (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const useMenu = (location: string): Menu | undefined =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => {
|
||||
if (!menuCache.has(location)) {
|
||||
menuCache.set(location, getMenu(location));
|
||||
}
|
||||
return menuCache.get(location);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
|
||||
listener: (e: MenuItemRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
|
||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const menus: typeof menusApi = {
|
||||
registerMenuItem,
|
||||
getMenu,
|
||||
onDidRegisterMenuItem,
|
||||
onDidUnregisterMenuItem,
|
||||
};
|
||||
|
||||
@@ -24,15 +24,13 @@
|
||||
* Extensions register views as side effects at import time.
|
||||
*/
|
||||
|
||||
import React, { ReactElement, useSyncExternalStore } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
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';
|
||||
|
||||
type View = viewsApi.View;
|
||||
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
|
||||
type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
|
||||
|
||||
const viewRegistry: Map<
|
||||
string,
|
||||
@@ -41,27 +39,6 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
const syncListeners = new Set<() => void>();
|
||||
const subscribe = (listener: () => void) => {
|
||||
syncListeners.add(listener);
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
|
||||
|
||||
const viewsCache = new Map<string, View[] | undefined>();
|
||||
const notifyRegister = (event: ViewRegisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
};
|
||||
const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -74,12 +51,10 @@ const registerView: typeof viewsApi.registerView = (
|
||||
const ids = locationIndex.get(location) ?? new Set();
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
notifyRegister({ view, location });
|
||||
|
||||
return new Disposable(() => {
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
notifyUnregister({ view, location });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -102,35 +77,7 @@ const getViews: typeof viewsApi.getViews = (
|
||||
.filter((c): c is View => !!c);
|
||||
};
|
||||
|
||||
export const useViews = (location: string): View[] | undefined =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => {
|
||||
if (!viewsCache.has(location)) {
|
||||
viewsCache.set(location, getViews(location));
|
||||
}
|
||||
return viewsCache.get(location);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
|
||||
listener: (e: ViewRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
|
||||
listener: (e: ViewUnregisteredEvent) => void,
|
||||
): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
getViews,
|
||||
onDidRegisterView,
|
||||
onDidUnregisterView,
|
||||
};
|
||||
|
||||
@@ -960,92 +960,3 @@ test('Clicking the gear "Add or edit filters and controls" item opens the Filter
|
||||
|
||||
expect(await screen.findByTestId('filter-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('FilterBar with orientation=Horizontal routes to Horizontal layout instead of Vertical', async () => {
|
||||
// Migrated from the disabled Cypress spec _skip.horizontalFilterBar.test.ts:
|
||||
// proves the orientation prop selects the Horizontal subtree. The settings
|
||||
// gear (FilterBarSettings) is rendered only by Horizontal.tsx — Vertical.tsx
|
||||
// does not mount it — so its presence is a horizontal-exclusive positive
|
||||
// signal that won't false-pass if vertical heading copy is tuned. We flush
|
||||
// all pending fake timers to clear useInitialization's setTimeout
|
||||
// regardless of the production timeout literal.
|
||||
const filter = createFilter({
|
||||
id: 'NATIVE_FILTER-h1',
|
||||
name: 'Horizontal filter',
|
||||
});
|
||||
const dataMask = createDataMask(filter.id);
|
||||
const state = createStateWithFilter(filter, dataMask, {
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
});
|
||||
|
||||
render(<FilterBar orientation={FilterBarOrientation.Horizontal} />, {
|
||||
initialState: state,
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('img', { name: 'setting' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('FilterBar with orientation=Horizontal and no filters shows empty state alongside default actions', async () => {
|
||||
// Covers the second half of sc-107387 task #107390 ("show all default
|
||||
// actions in horizontal mode"). The original Cypress spec asserted four
|
||||
// affordances render when the bar is horizontal with no filters: the
|
||||
// empty-state copy, the settings gear, the action-buttons block, and the
|
||||
// create-filter entry inside the gear menu. The dropdown contents are
|
||||
// already covered by FilterBarSettings.test.tsx; here we keep scope to
|
||||
// the layout-level affordances that are exclusive to Horizontal.tsx.
|
||||
// Reload-persistence (the rest of #107390) is out of RTL scope and stays
|
||||
// queued for Playwright.
|
||||
const state = {
|
||||
...stateWithoutNativeFilters,
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
metadata: {
|
||||
native_filter_configuration: [],
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...stateWithoutNativeFilters.dashboardState,
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
nativeFilters: { filters: {}, filtersState: {} },
|
||||
};
|
||||
|
||||
render(<FilterBar orientation={FilterBarOrientation.Horizontal} />, {
|
||||
initialState: state,
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('horizontal-filterbar-empty')).toHaveTextContent(
|
||||
'No filters are currently added to this dashboard.',
|
||||
);
|
||||
expect(screen.getByRole('img', { name: 'setting' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filterbar-action-buttons')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('FilterBar with orientation=Vertical renders Vertical layout (sanity counterpart to the horizontal routing test)', () => {
|
||||
// Paired control for the routing test above: with Vertical orientation,
|
||||
// the settings gear must NOT be present (Vertical.tsx does not render
|
||||
// FilterBarSettings). Confirms the routing signal is horizontal-exclusive,
|
||||
// not a coincidence of when timers fire.
|
||||
const props = createClosedBarProps();
|
||||
renderFilterBar(props);
|
||||
expect(screen.getByText('Filters and controls')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('img', { name: 'setting' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* 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 { Preset } from '@superset-ui/core';
|
||||
import type { DataMaskStateWithId } from '@superset-ui/core';
|
||||
import type {
|
||||
DropdownContainerProps,
|
||||
DropdownItem,
|
||||
} from '@superset-ui/core/components/DropdownContainer';
|
||||
import { SelectFilterPlugin } from 'src/filters/components';
|
||||
import { FilterBarOrientation } from 'src/dashboard/types';
|
||||
import { act, render, waitFor, within } from 'spec/helpers/testing-library';
|
||||
import { createSelectNativeFilter } from 'spec/fixtures/mockNativeFilters';
|
||||
import FilterControls from './FilterControls';
|
||||
|
||||
// Capture every props snapshot DropdownContainer receives, plus the latest
|
||||
// onOverflowingStateChange callback. Tests drive overflow by invoking the
|
||||
// callback and then assert against the *next* captured props snapshot —
|
||||
// these are the values FilterControls itself computed (dropdownTriggerCount,
|
||||
// dropdownContent, items) so assertions exercise real production logic
|
||||
// rather than props the test handed in directly.
|
||||
const dropdownContainerProps: DropdownContainerProps[] = [];
|
||||
const callbackRef: {
|
||||
current:
|
||||
| ((s: { overflowed: string[]; notOverflowed: string[] }) => void)
|
||||
| null;
|
||||
} = { current: null };
|
||||
|
||||
// Mock the DropdownContainer subpath rather than the barrel
|
||||
// `@superset-ui/core/components` — mocking the barrel triggers a
|
||||
// circular re-export chain at requireActual time
|
||||
// (LabeledErrorBoundInput → ActionButton is undefined at that point).
|
||||
// The barrel's `export { DropdownContainer } from './DropdownContainer'`
|
||||
// resolves to this subpath, so the mock is picked up transparently.
|
||||
jest.mock('@superset-ui/core/components/DropdownContainer', () => {
|
||||
const React = jest.requireActual('react');
|
||||
const MockDropdownContainer = React.forwardRef(
|
||||
(props: DropdownContainerProps, ref: React.Ref<unknown>) => {
|
||||
dropdownContainerProps.push(props);
|
||||
callbackRef.current = props.onOverflowingStateChange ?? null;
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
open: jest.fn(),
|
||||
close: jest.fn(),
|
||||
}));
|
||||
return (
|
||||
<div data-test="dropdown-container-mock">
|
||||
<div data-test="dropdown-items">
|
||||
{props.items.map((item: DropdownItem) => (
|
||||
<div key={item.id} data-test="dropdown-item">
|
||||
{item.element}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-test="dropdown-trigger-text">
|
||||
{props.dropdownTriggerText}
|
||||
</div>
|
||||
<div data-test="dropdown-trigger-count">
|
||||
{props.dropdownTriggerCount}
|
||||
</div>
|
||||
{props.dropdownContent && (
|
||||
<div data-test="dropdown-content-mock">
|
||||
{props.dropdownContent([])}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
return { __esModule: true, DropdownContainer: MockDropdownContainer };
|
||||
});
|
||||
|
||||
class OverflowTestPreset extends Preset {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'FilterControls overflow test preset',
|
||||
plugins: [new SelectFilterPlugin().configure({ key: 'filter_select' })],
|
||||
});
|
||||
}
|
||||
}
|
||||
new OverflowTestPreset().register();
|
||||
|
||||
// Tabless dashboard layout ⇒ useSelectFiltersInScope returns all filters in
|
||||
// scope without needing to model tab parentage.
|
||||
const buildHorizontalState = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
) => ({
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
metadata: {
|
||||
native_filter_configuration: filters,
|
||||
},
|
||||
},
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: [] },
|
||||
},
|
||||
past: [],
|
||||
future: [],
|
||||
},
|
||||
dashboardState: {
|
||||
sliceIds: [],
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
charts: {},
|
||||
nativeFilters: {
|
||||
filters: filters.reduce(
|
||||
(acc, f) => ({ ...acc, [f.id]: f }),
|
||||
{} as Record<string, ReturnType<typeof createSelectNativeFilter>>,
|
||||
),
|
||||
filtersState: {},
|
||||
},
|
||||
dataMask: {},
|
||||
sliceEntities: { slices: {} },
|
||||
datasources: {},
|
||||
});
|
||||
|
||||
const buildDataMaskSelected = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
withValueIds: string[] = [],
|
||||
): DataMaskStateWithId =>
|
||||
filters.reduce(
|
||||
(acc, f) => ({
|
||||
...acc,
|
||||
[f.id]: {
|
||||
id: f.id,
|
||||
filterState: {
|
||||
value: withValueIds.includes(f.id) ? ['set'] : null,
|
||||
},
|
||||
extraFormData: {},
|
||||
},
|
||||
}),
|
||||
{} as DataMaskStateWithId,
|
||||
);
|
||||
|
||||
const renderHorizontal = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
dataMaskSelected: DataMaskStateWithId,
|
||||
) =>
|
||||
render(
|
||||
<FilterControls
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
onFilterSelectionChange={jest.fn()}
|
||||
onPendingCustomizationDataMaskChange={jest.fn()}
|
||||
chartCustomizationValues={[]}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: buildHorizontalState(filters),
|
||||
},
|
||||
);
|
||||
|
||||
const latestProps = () =>
|
||||
dropdownContainerProps[dropdownContainerProps.length - 1];
|
||||
|
||||
const fireOverflow = (overflowed: string[], notOverflowed: string[]) => {
|
||||
if (!callbackRef.current) {
|
||||
throw new Error('onOverflowingStateChange callback not captured');
|
||||
}
|
||||
act(() => {
|
||||
callbackRef.current!({ overflowed, notOverflowed });
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dropdownContainerProps.length = 0;
|
||||
callbackRef.current = null;
|
||||
});
|
||||
|
||||
test('horizontal FilterControls hands every filter to DropdownContainer as an item', async () => {
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'country'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'region'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-3', 'city'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-4', 'zip'),
|
||||
];
|
||||
|
||||
renderHorizontal(filters, buildDataMaskSelected(filters));
|
||||
|
||||
await waitFor(() => expect(latestProps()).toBeTruthy());
|
||||
|
||||
expect(latestProps().items.map((i: DropdownItem) => i.id)).toEqual([
|
||||
'NATIVE_FILTER-1',
|
||||
'NATIVE_FILTER-2',
|
||||
'NATIVE_FILTER-3',
|
||||
'NATIVE_FILTER-4',
|
||||
]);
|
||||
// dropdownTriggerText is the production string FilterControls passes in.
|
||||
expect(latestProps().dropdownTriggerText).toBe('More filters');
|
||||
});
|
||||
|
||||
test('with no overflow callback fired, dropdown trigger count is 0 and content is empty', async () => {
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'country'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'region'),
|
||||
];
|
||||
|
||||
renderHorizontal(
|
||||
filters,
|
||||
buildDataMaskSelected(filters, ['NATIVE_FILTER-1']),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(latestProps()).toBeTruthy());
|
||||
|
||||
expect(latestProps().dropdownTriggerCount).toBe(0);
|
||||
// FilterControls only supplies dropdownContent when something overflowed.
|
||||
expect(latestProps().dropdownContent).toBeUndefined();
|
||||
});
|
||||
|
||||
test('firing overflow with two filters that have values increments the trigger count to 2', async () => {
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'country'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'region'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-3', 'city'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-4', 'zip'),
|
||||
];
|
||||
|
||||
renderHorizontal(
|
||||
filters,
|
||||
// Mark the two we plan to overflow as having values; the production
|
||||
// selector activeOverflowedFiltersInScope filters on dataMask.filterState.value.
|
||||
buildDataMaskSelected(filters, ['NATIVE_FILTER-3', 'NATIVE_FILTER-4']),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(callbackRef.current).toBeTruthy());
|
||||
|
||||
fireOverflow(
|
||||
['NATIVE_FILTER-3', 'NATIVE_FILTER-4'],
|
||||
['NATIVE_FILTER-1', 'NATIVE_FILTER-2'],
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latestProps().dropdownTriggerCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('firing overflow with no active values keeps trigger count at 0 but supplies dropdownContent', async () => {
|
||||
// Reinforces the activeOverflowedFiltersInScope branch in
|
||||
// FilterControls.tsx: count is the *active* (value-bearing) subset of
|
||||
// overflowed filters, not the raw overflowed count. If the production
|
||||
// memo regressed to use overflowedFiltersInScope.length, this fails.
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'country'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'region'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-3', 'city'),
|
||||
];
|
||||
|
||||
renderHorizontal(filters, buildDataMaskSelected(filters));
|
||||
|
||||
await waitFor(() => expect(callbackRef.current).toBeTruthy());
|
||||
|
||||
fireOverflow(['NATIVE_FILTER-2', 'NATIVE_FILTER-3'], ['NATIVE_FILTER-1']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latestProps().dropdownContent).toBeInstanceOf(Function);
|
||||
});
|
||||
expect(latestProps().dropdownTriggerCount).toBe(0);
|
||||
});
|
||||
|
||||
test('all 12 overflowed filters are reachable through dropdownContent', async () => {
|
||||
// Substitutes for the disabled Cypress "scroll within overflow" assertion:
|
||||
// jsdom has no real layout/scrolling, so we instead prove every overflowed
|
||||
// filter renders inside the dropdown panel.
|
||||
const filters = Array.from({ length: 12 }, (_, i) =>
|
||||
createSelectNativeFilter(`NATIVE_FILTER-${i + 1}`, `filter_${i + 1}`),
|
||||
);
|
||||
|
||||
renderHorizontal(filters, buildDataMaskSelected(filters));
|
||||
|
||||
await waitFor(() => expect(callbackRef.current).toBeTruthy());
|
||||
|
||||
fireOverflow(
|
||||
filters.map(f => f.id),
|
||||
[],
|
||||
);
|
||||
|
||||
// dropdownContent renders FiltersDropdownContent, which renders each
|
||||
// overflowed filter through the renderer prop. Asserting on identity
|
||||
// (not just count) catches a regression that rendered the wrong subset
|
||||
// of filters in the dropdown — e.g. all `filtersInScope` instead of
|
||||
// the overflowed slice.
|
||||
const { findByTestId } = within(document.body);
|
||||
const contentSlot = await findByTestId('dropdown-content-mock');
|
||||
await waitFor(() => {
|
||||
const names = within(contentSlot).getAllByTestId('filter-control-name');
|
||||
expect(names.map(n => n.textContent)).toEqual(filters.map(f => f.name));
|
||||
});
|
||||
});
|
||||
@@ -16,26 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { NativeFilterType, Preset } from '@superset-ui/core';
|
||||
import type { Filter } from '@superset-ui/core';
|
||||
import { SelectFilterPlugin } from 'src/filters/components';
|
||||
import { FilterBarOrientation } from 'src/dashboard/types';
|
||||
import { NativeFilterType } from '@superset-ui/core';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { createSelectNativeFilter } from 'spec/fixtures/mockNativeFilters';
|
||||
import HorizontalBar from './Horizontal';
|
||||
import type { HorizontalBarProps } from './types';
|
||||
|
||||
// Register the select filter plugin once so FilterControl can render the
|
||||
// filter name without throwing when the plugin registry is consulted.
|
||||
class HorizontalFilterBarTestPreset extends Preset {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'Horizontal filter bar test preset',
|
||||
plugins: [new SelectFilterPlugin().configure({ key: 'filter_select' })],
|
||||
});
|
||||
}
|
||||
}
|
||||
new HorizontalFilterBarTestPreset().register();
|
||||
|
||||
const defaultProps = {
|
||||
actions: null,
|
||||
@@ -49,7 +32,7 @@ const defaultProps = {
|
||||
onPendingCustomizationDataMaskChange: jest.fn(),
|
||||
};
|
||||
|
||||
const renderWrapper = (overrideProps?: Partial<HorizontalBarProps>) =>
|
||||
const renderWrapper = (overrideProps?: Record<string, any>) =>
|
||||
waitFor(() =>
|
||||
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
|
||||
useRedux: true,
|
||||
@@ -77,13 +60,11 @@ test('should render', async () => {
|
||||
|
||||
test('should not render the empty message', async () => {
|
||||
await renderWrapper({
|
||||
// Intentionally minimal — Horizontal only reads filterValues.length
|
||||
// here, so the missing required Filter fields would never be read.
|
||||
filterValues: [
|
||||
{
|
||||
id: 'test',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
} as unknown as Filter,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
@@ -111,133 +92,3 @@ test('should render the loading icon', async () => {
|
||||
});
|
||||
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Tests migrated from disabled Cypress spec
|
||||
// `_skip.horizontalFilterBar.test.ts` (sc-107387). ---
|
||||
|
||||
const buildStateWithFilters = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
) => ({
|
||||
dashboardState: {
|
||||
sliceIds: [],
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
metadata: {
|
||||
native_filter_configuration: filters,
|
||||
},
|
||||
},
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: [] },
|
||||
},
|
||||
past: [],
|
||||
future: [],
|
||||
},
|
||||
charts: {},
|
||||
nativeFilters: {
|
||||
filters: filters.reduce(
|
||||
(acc, f) => ({ ...acc, [f.id]: f }),
|
||||
{} as Record<string, ReturnType<typeof createSelectNativeFilter>>,
|
||||
),
|
||||
filtersState: {},
|
||||
},
|
||||
dataMask: filters.reduce(
|
||||
(acc, f) => ({
|
||||
...acc,
|
||||
[f.id]: { id: f.id, filterState: { value: null }, extraFormData: {} },
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
),
|
||||
sliceEntities: { slices: {} },
|
||||
datasources: {},
|
||||
});
|
||||
|
||||
const renderWithFilters = (
|
||||
filters: ReturnType<typeof createSelectNativeFilter>[],
|
||||
overrideProps?: Partial<HorizontalBarProps>,
|
||||
) =>
|
||||
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: buildStateWithFilters(filters),
|
||||
});
|
||||
|
||||
test('renders default actions slot, settings gear, and empty message together in horizontal mode', async () => {
|
||||
const sentinelActions = (
|
||||
<button type="button" data-test="sentinel-actions">
|
||||
apply
|
||||
</button>
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
render(
|
||||
<HorizontalBar
|
||||
{...defaultProps}
|
||||
actions={sentinelActions}
|
||||
filterValues={[]}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardState: { sliceIds: [] },
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
},
|
||||
dashboardLayout: { present: {}, past: [], future: [] },
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('No filters are currently added to this dashboard.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'setting' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sentinel-actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders all native filters supplied via filterValues in horizontal mode', async () => {
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'test_1', 'country_name'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-2', 'test_2', 'country_code'),
|
||||
createSelectNativeFilter('NATIVE_FILTER-3', 'test_3', 'region'),
|
||||
];
|
||||
|
||||
renderWithFilters(filters, { filterValues: filters });
|
||||
|
||||
await waitFor(() => {
|
||||
const filterNames = screen.getAllByTestId('filter-control-name');
|
||||
expect(filterNames).toHaveLength(3);
|
||||
});
|
||||
|
||||
['test_1', 'test_2', 'test_3'].forEach(name => {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('omits the empty message when at least one filter value is supplied', async () => {
|
||||
// Companion to the "renders all native filters" test above: the migrated
|
||||
// Cypress "display newly added filter" scenario reduces, at this layer, to
|
||||
// proving that supplying a filter value flips off the empty state. The
|
||||
// upstream user flow (open edit modal, add filter, save) is integration
|
||||
// territory and not covered here.
|
||||
const filters = [
|
||||
createSelectNativeFilter('NATIVE_FILTER-1', 'just_added', 'country_name'),
|
||||
];
|
||||
|
||||
renderWithFilters(filters, { filterValues: filters });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('just_added')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByText('No filters are currently added to this dashboard.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
@@ -52,12 +52,20 @@ declare global {
|
||||
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ user }) => user.userId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId == null) return;
|
||||
if (initialized) return;
|
||||
|
||||
if (!userId) {
|
||||
// No user logged in — nothing to initialize
|
||||
setInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Provide the implementations for @apache-superset/core
|
||||
window.superset = {
|
||||
@@ -72,10 +80,19 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
views,
|
||||
};
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
}, [userId]);
|
||||
const setup = async () => {
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
await ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
setInitialized(true);
|
||||
};
|
||||
|
||||
setup();
|
||||
}, [initialized, userId]);
|
||||
|
||||
if (!initialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
2
superset-websocket/package-lock.json
generated
2
superset-websocket/package-lock.json
generated
@@ -25,7 +25,7 @@
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
118
superset-websocket/utils/client-ws-app/package-lock.json
generated
118
superset-websocket/utils/client-ws-app/package-lock.json
generated
@@ -13,7 +13,7 @@
|
||||
"express": "~5.2.1",
|
||||
"http-errors": "~2.0.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"morgan": "~1.11.0",
|
||||
"morgan": "~1.10.1",
|
||||
"pug": "~3.0.4"
|
||||
}
|
||||
},
|
||||
@@ -127,6 +127,17 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -444,6 +455,17 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||
@@ -461,6 +483,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -833,22 +867,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
|
||||
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||
"dependencies": {
|
||||
"basic-auth": "~2.0.1",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-finished": "~2.4.1",
|
||||
"on-finished": "~2.3.0",
|
||||
"on-headers": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan/node_modules/debug": {
|
||||
@@ -898,9 +928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
@@ -1197,6 +1227,18 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
@@ -1467,6 +1509,16 @@
|
||||
"qs": "^6.14.0",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"requires": {
|
||||
"ee-first": "1.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"buffer-equal-constant-time": {
|
||||
@@ -1688,6 +1740,14 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="
|
||||
},
|
||||
"on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"requires": {
|
||||
"ee-first": "1.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1702,6 +1762,16 @@
|
||||
"on-finished": "^2.4.1",
|
||||
"parseurl": "^1.3.3",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"requires": {
|
||||
"ee-first": "1.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwarded": {
|
||||
@@ -1970,14 +2040,14 @@
|
||||
}
|
||||
},
|
||||
"morgan": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
|
||||
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||
"requires": {
|
||||
"basic-auth": "~2.0.1",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-finished": "~2.4.1",
|
||||
"on-finished": "~2.3.0",
|
||||
"on-headers": "~1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -2017,9 +2087,9 @@
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
|
||||
},
|
||||
"on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
|
||||
"requires": {
|
||||
"ee-first": "1.1.1"
|
||||
}
|
||||
@@ -2267,6 +2337,16 @@
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"requires": {
|
||||
"ee-first": "1.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"express": "~5.2.1",
|
||||
"http-errors": "~2.0.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"morgan": "~1.11.0",
|
||||
"morgan": "~1.10.1",
|
||||
"pug": "~3.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,6 @@ LANGUAGES = {
|
||||
"nl": {"flag": "nl", "name": "Dutch"},
|
||||
"uk": {"flag": "ua", "name": "Ukrainian"},
|
||||
"mi": {"flag": "nz", "name": "Māori"},
|
||||
"ro": {"flag": "ro", "name": "Romanian"},
|
||||
}
|
||||
# Turning off i18n by default as translation in most languages are
|
||||
# incomplete and not well maintained.
|
||||
@@ -1685,9 +1684,14 @@ SMTP_USER = "superset"
|
||||
SMTP_PORT = 25
|
||||
SMTP_PASSWORD = "superset" # noqa: S105
|
||||
SMTP_MAIL_FROM = "superset@superset.com"
|
||||
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
|
||||
# default system root CA certificates.
|
||||
SMTP_SSL_SERVER_AUTH = False
|
||||
# If True creates a default SSL context with ssl.Purpose.SERVER_AUTH using the
|
||||
# default system root CA certificates. This makes STARTTLS/SSL connections to the
|
||||
# SMTP server validate the server's certificate against the trusted CA store.
|
||||
# Defaults to True so the mail server identity is verified out of the box. Set to
|
||||
# False to restore the previous behavior of skipping certificate validation (for
|
||||
# example, when using a self-signed certificate that is not in the system CA
|
||||
# store).
|
||||
SMTP_SSL_SERVER_AUTH = True
|
||||
ENABLE_CHUNK_ENCODING = False
|
||||
|
||||
# Whether to bump the logging level to ERROR on the flask_appbuilder package
|
||||
|
||||
@@ -129,7 +129,6 @@ Dashboard Management:
|
||||
- get_dashboard_info: Get detailed dashboard information by ID
|
||||
- get_dashboard_layout: Get parsed tabs and chart positions for a dashboard (companion to get_dashboard_info when its omitted_fields hint flags position_json)
|
||||
- generate_dashboard: Create a dashboard from chart IDs (requires write access)
|
||||
- duplicate_dashboard: Duplicate an existing dashboard, optionally deep-copying its charts (requires write access)
|
||||
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
|
||||
|
||||
Annotation Layers:
|
||||
@@ -414,9 +413,8 @@ 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, duplicate_dashboard, update_chart,
|
||||
create_virtual_dataset, save_sql_query, add_chart_to_existing_dashboard,
|
||||
update_chart_preview) require write
|
||||
- Write tools (generate_chart, generate_dashboard, update_chart, 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.
|
||||
- execute_sql requires SQL Lab access (execute_sql_query permission), which is separate
|
||||
@@ -681,7 +679,6 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
|
||||
)
|
||||
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
|
||||
add_chart_to_existing_dashboard,
|
||||
duplicate_dashboard,
|
||||
generate_dashboard,
|
||||
get_dashboard_info,
|
||||
get_dashboard_layout,
|
||||
|
||||
@@ -708,138 +708,6 @@ class GenerateDashboardResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class DuplicateDashboardRequest(BaseModel):
|
||||
"""Request schema for duplicating an existing dashboard."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
dashboard_id: Annotated[
|
||||
int | str,
|
||||
Field(
|
||||
description=(
|
||||
"Source dashboard identifier - can be numeric ID, UUID string, or slug"
|
||||
)
|
||||
),
|
||||
]
|
||||
dashboard_title: str = Field(
|
||||
...,
|
||||
description="Title for the new (duplicated) dashboard",
|
||||
validation_alias=AliasChoices("dashboard_title", "title", "name"),
|
||||
)
|
||||
duplicate_slices: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When true, every chart on the source dashboard is deep-copied "
|
||||
"into a new chart object owned by the caller. When false "
|
||||
"(default), the new dashboard references the same charts as the "
|
||||
"source."
|
||||
),
|
||||
)
|
||||
sanitization_warnings: List[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Internal: warnings emitted when user input was altered by "
|
||||
"sanitization. Populated by the ``mode='before'`` validator "
|
||||
"before dashboard_title is rewritten, so the tool can surface "
|
||||
"a notice to the caller instead of silently dropping content."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _detect_dashboard_title_sanitization(cls, data: Any) -> Any:
|
||||
"""Reject empty-after-sanitization titles and warn on partial strip.
|
||||
|
||||
Runs before the ``dashboard_title`` field validator rewrites the
|
||||
value. If the caller supplied a title that sanitization would strip
|
||||
entirely (XSS-only content), we raise so the caller gets a clear
|
||||
error instead of a blank-titled dashboard. When the sanitizer only
|
||||
trims part of the title, we record a warning the tool can return
|
||||
alongside the successful result.
|
||||
|
||||
``sanitization_warnings`` is a server-only field — any value the
|
||||
caller supplied is discarded here so the tool cannot be tricked
|
||||
into echoing attacker-controlled text back through the response.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
data["sanitization_warnings"] = []
|
||||
for key in ("dashboard_title", "title", "name"):
|
||||
if key in data:
|
||||
raw = data[key]
|
||||
break
|
||||
else:
|
||||
raw = None
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return data
|
||||
sanitized, was_modified = sanitize_user_input_with_changes(
|
||||
raw, "Dashboard title", max_length=500, allow_empty=True
|
||||
)
|
||||
if was_modified and not sanitized:
|
||||
raise ValueError(
|
||||
"dashboard_title contained only disallowed content "
|
||||
"(HTML/script/URL schemes) and was removed entirely by "
|
||||
"sanitization. Provide a dashboard_title with plain text."
|
||||
)
|
||||
if was_modified:
|
||||
data["sanitization_warnings"].append(
|
||||
"dashboard_title was modified during sanitization to "
|
||||
"remove potentially unsafe content; the stored title "
|
||||
"differs from the input."
|
||||
)
|
||||
return data
|
||||
|
||||
@field_validator("dashboard_title")
|
||||
@classmethod
|
||||
def sanitize_dashboard_title(cls, v: str) -> str:
|
||||
"""Sanitize dashboard title to prevent XSS."""
|
||||
sanitized = sanitize_user_input(
|
||||
v, "Dashboard title", max_length=500, allow_empty=True
|
||||
)
|
||||
if not sanitized:
|
||||
raise ValueError("dashboard_title cannot be empty")
|
||||
return sanitized
|
||||
|
||||
|
||||
class DuplicateDashboardResponse(BaseModel):
|
||||
"""Response schema for dashboard duplication."""
|
||||
|
||||
dashboard: DashboardInfo | None = Field(
|
||||
None, description="The newly created dashboard info, if successful"
|
||||
)
|
||||
dashboard_url: str | None = Field(None, description="URL to view the new dashboard")
|
||||
duplicated_slices: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"True when the source dashboard's charts were deep-copied into "
|
||||
"new chart objects; False when the new dashboard references the "
|
||||
"original charts."
|
||||
),
|
||||
)
|
||||
error: str | None = Field(None, description="Error message, if duplication failed")
|
||||
warnings: List[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Non-fatal advisory messages about the duplicated dashboard — "
|
||||
"for example, that the supplied title was altered by "
|
||||
"sanitization."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
|
||||
"""Wrap error text before it is exposed to LLM context.
|
||||
|
||||
The error may echo dashboard-controlled content such as the source
|
||||
dashboard title — wrap it so the LLM treats it as data, not
|
||||
instructions.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
|
||||
|
||||
class ChartPosition(BaseModel):
|
||||
"""Position and identity of a chart within a dashboard layout."""
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
# under the License.
|
||||
|
||||
from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
|
||||
from .duplicate_dashboard import duplicate_dashboard
|
||||
from .generate_dashboard import generate_dashboard
|
||||
from .get_dashboard_info import get_dashboard_info
|
||||
from .get_dashboard_layout import get_dashboard_layout
|
||||
@@ -27,6 +26,5 @@ __all__ = [
|
||||
"get_dashboard_info",
|
||||
"get_dashboard_layout",
|
||||
"generate_dashboard",
|
||||
"duplicate_dashboard",
|
||||
"add_chart_to_existing_dashboard",
|
||||
]
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
MCP tool: duplicate_dashboard
|
||||
|
||||
Duplicates an existing dashboard, optionally deep-copying its charts.
|
||||
Canonical workflow: clone a template dashboard, then edit the copy
|
||||
(e.g. to create a regional or staging variant).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Context
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.dashboard.schemas import (
|
||||
DashboardInfo,
|
||||
DuplicateDashboardRequest,
|
||||
DuplicateDashboardResponse,
|
||||
serialize_chart_summary,
|
||||
)
|
||||
from superset.mcp_service.privacy import user_can_view_data_model_metadata
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
from superset.utils import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_copy_payload(
|
||||
source: Any, dashboard_title: str, duplicate_slices: bool
|
||||
) -> dict[str, Any]:
|
||||
"""Build the data payload expected by ``CopyDashboardCommand``.
|
||||
|
||||
Mirrors what the frontend "Save as" flow sends to the
|
||||
``/api/v1/dashboard/<id>/copy/`` endpoint: the source dashboard's
|
||||
current ``json_metadata`` with a ``positions`` key holding the current
|
||||
layout (``position_json``). ``DashboardCopySchema`` requires
|
||||
``json_metadata``, and ``DashboardDAO.copy_dashboard`` reads
|
||||
``positions`` from it to remap chart IDs when ``duplicate_slices``
|
||||
is enabled.
|
||||
"""
|
||||
try:
|
||||
metadata = json.loads(source.json_metadata or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadata = {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
try:
|
||||
positions = json.loads(source.position_json or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
positions = {}
|
||||
if not isinstance(positions, dict):
|
||||
positions = {}
|
||||
|
||||
metadata["positions"] = positions
|
||||
|
||||
return {
|
||||
"dashboard_title": dashboard_title,
|
||||
"css": source.css,
|
||||
"duplicate_slices": duplicate_slices,
|
||||
"json_metadata": json.dumps(metadata),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_new_dashboard(dashboard: Any) -> tuple[DashboardInfo, str]:
|
||||
"""Build the response ``DashboardInfo`` and URL for the new dashboard."""
|
||||
from superset.mcp_service.dashboard.schemas import serialize_tag_object
|
||||
|
||||
dashboard_url = f"{get_superset_base_url()}/superset/dashboard/{dashboard.id}/"
|
||||
include_data_model_metadata = user_can_view_data_model_metadata()
|
||||
info = DashboardInfo(
|
||||
id=dashboard.id,
|
||||
dashboard_title=dashboard.dashboard_title,
|
||||
slug=dashboard.slug,
|
||||
description=dashboard.description,
|
||||
published=dashboard.published,
|
||||
created_on=dashboard.created_on,
|
||||
changed_on=dashboard.changed_on,
|
||||
uuid=str(dashboard.uuid) if dashboard.uuid else None,
|
||||
url=dashboard_url,
|
||||
chart_count=len(dashboard.slices),
|
||||
tags=[
|
||||
obj
|
||||
for tag in getattr(dashboard, "tags", [])
|
||||
if (obj := serialize_tag_object(tag)) is not None
|
||||
],
|
||||
charts=[
|
||||
obj
|
||||
for chart in getattr(dashboard, "slices", [])
|
||||
if (
|
||||
obj := serialize_chart_summary(
|
||||
chart,
|
||||
include_data_model_metadata=include_data_model_metadata,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
],
|
||||
)
|
||||
return info, dashboard_url
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Dashboard",
|
||||
method_permission_name="write",
|
||||
annotations=ToolAnnotations(
|
||||
title="Duplicate dashboard",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def duplicate_dashboard(
|
||||
request: DuplicateDashboardRequest, ctx: Context
|
||||
) -> DuplicateDashboardResponse:
|
||||
"""
|
||||
Duplicate an existing dashboard under a new title.
|
||||
|
||||
By default the copy references the same charts as the source.
|
||||
Set duplicate_slices=true to also deep-copy every chart into new
|
||||
chart objects owned by you, so edits to the copies never affect
|
||||
the originals.
|
||||
|
||||
The source dashboard can be identified by numeric ID, UUID, or slug.
|
||||
Returns the new dashboard's ID, title, and URL.
|
||||
"""
|
||||
await ctx.info(
|
||||
"Duplicating dashboard: dashboard_id=%s, duplicate_slices=%s"
|
||||
% (request.dashboard_id, request.duplicate_slices)
|
||||
)
|
||||
|
||||
from superset.commands.dashboard.copy import CopyDashboardCommand
|
||||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardAccessDeniedError,
|
||||
DashboardCopyError,
|
||||
DashboardForbiddenError,
|
||||
DashboardInvalidError,
|
||||
DashboardNotFoundError,
|
||||
)
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
|
||||
try:
|
||||
with event_logger.log_context(action="mcp.duplicate_dashboard.lookup"):
|
||||
try:
|
||||
source = DashboardDAO.get_by_id_or_slug(str(request.dashboard_id))
|
||||
except DashboardNotFoundError:
|
||||
return DuplicateDashboardResponse(
|
||||
error=(
|
||||
f"Dashboard '{request.dashboard_id}' not found. "
|
||||
"Use list_dashboards to get valid dashboard IDs."
|
||||
),
|
||||
)
|
||||
except DashboardAccessDeniedError:
|
||||
return DuplicateDashboardResponse(
|
||||
error=(
|
||||
f"You don't have access to dashboard "
|
||||
f"'{request.dashboard_id}', so it cannot be duplicated."
|
||||
),
|
||||
)
|
||||
|
||||
data = _build_copy_payload(
|
||||
source, request.dashboard_title, request.duplicate_slices
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.duplicate_dashboard.copy"):
|
||||
new_dashboard = CopyDashboardCommand(source, data).run()
|
||||
|
||||
# Re-fetch with eager-loaded relationships to avoid lazy-loading on
|
||||
# a session that the command's commit may have invalidated.
|
||||
from sqlalchemy.orm import subqueryload
|
||||
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
|
||||
try:
|
||||
new_dashboard = (
|
||||
DashboardDAO.find_by_id(
|
||||
new_dashboard.id,
|
||||
query_options=[
|
||||
subqueryload(Dashboard.slices).subqueryload(Slice.tags),
|
||||
subqueryload(Dashboard.tags),
|
||||
],
|
||||
)
|
||||
or new_dashboard
|
||||
)
|
||||
info, dashboard_url = _serialize_new_dashboard(new_dashboard)
|
||||
except SQLAlchemyError:
|
||||
logger.warning(
|
||||
"Re-fetch of dashboard %s failed; returning minimal response",
|
||||
new_dashboard.id,
|
||||
exc_info=True,
|
||||
)
|
||||
dashboard_url = (
|
||||
f"{get_superset_base_url()}/superset/dashboard/{new_dashboard.id}/"
|
||||
)
|
||||
info = DashboardInfo(
|
||||
id=new_dashboard.id,
|
||||
dashboard_title=request.dashboard_title,
|
||||
url=dashboard_url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Duplicated dashboard %s into dashboard %s (duplicate_slices=%s)",
|
||||
request.dashboard_id,
|
||||
new_dashboard.id,
|
||||
request.duplicate_slices,
|
||||
)
|
||||
|
||||
return DuplicateDashboardResponse(
|
||||
dashboard=info,
|
||||
dashboard_url=dashboard_url,
|
||||
duplicated_slices=request.duplicate_slices,
|
||||
warnings=list(request.sanitization_warnings),
|
||||
)
|
||||
|
||||
except DashboardForbiddenError:
|
||||
await ctx.error(
|
||||
"Dashboard duplication forbidden: dashboard_id=%s" % (request.dashboard_id,)
|
||||
)
|
||||
return DuplicateDashboardResponse(
|
||||
error=(
|
||||
f"You don't have permission to duplicate dashboard "
|
||||
f"'{request.dashboard_id}'."
|
||||
),
|
||||
)
|
||||
except DashboardInvalidError:
|
||||
return DuplicateDashboardResponse(
|
||||
error=(
|
||||
"Dashboard duplication parameters were invalid. "
|
||||
"Provide a non-empty dashboard_title."
|
||||
),
|
||||
)
|
||||
except DashboardCopyError as exc:
|
||||
from superset import db
|
||||
|
||||
try:
|
||||
db.session.rollback() # pylint: disable=consider-using-transaction
|
||||
except SQLAlchemyError:
|
||||
logger.warning(
|
||||
"Database rollback failed during error handling", exc_info=True
|
||||
)
|
||||
await ctx.error("Dashboard duplication failed: %s" % (str(exc),))
|
||||
return DuplicateDashboardResponse(
|
||||
error=f"Failed to duplicate dashboard: {exc}",
|
||||
)
|
||||
except Exception as exc:
|
||||
await ctx.error(
|
||||
"Unexpected error duplicating dashboard: %s: %s"
|
||||
% (type(exc).__name__, str(exc))
|
||||
)
|
||||
raise
|
||||
@@ -212,7 +212,6 @@ MCP_CACHE_CONFIG: Dict[str, Any] = {
|
||||
"excluded_tools": [ # Tools that should never be cached (side effects, dynamic)
|
||||
"execute_sql",
|
||||
"generate_dashboard",
|
||||
"duplicate_dashboard",
|
||||
"generate_chart",
|
||||
"update_chart",
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,6 @@ import pandas as pd
|
||||
import simplejson
|
||||
from flask_babel.speaklater import LazyString
|
||||
from jsonpath_ng import parse
|
||||
from jsonpath_ng.jsonpath import Child, Fields, Root
|
||||
from simplejson import JSONDecodeError
|
||||
|
||||
from superset.constants import PASSWORD_MASK
|
||||
@@ -305,30 +304,6 @@ def reveal_sensitive(
|
||||
return revealed_payload
|
||||
|
||||
|
||||
def _render_jsonpath(node: Any) -> str:
|
||||
"""
|
||||
Render a JSONPath node as a stable dotted string (e.g. ``foo.bar``).
|
||||
|
||||
``str()`` of a jsonpath-ng path is not stable across releases: as of
|
||||
jsonpath-ng 1.8.0 a ``Child`` node renders with surrounding parentheses
|
||||
(e.g. ``(foo.bar)``). This helper produces the historic dotted notation so
|
||||
that the strings returned by :func:`get_masked_fields` remain consistent and
|
||||
can be round-tripped back through ``parse``.
|
||||
|
||||
Falls back to ``str()`` for node kinds that aren't plain field access (e.g.
|
||||
array indices, slices), preserving their existing representation.
|
||||
"""
|
||||
if isinstance(node, Child):
|
||||
left = _render_jsonpath(node.left)
|
||||
right = _render_jsonpath(node.right)
|
||||
return f"{left}.{right}" if left else right
|
||||
if isinstance(node, Fields):
|
||||
return ".".join(node.fields)
|
||||
if isinstance(node, Root):
|
||||
return ""
|
||||
return str(node)
|
||||
|
||||
|
||||
def get_masked_fields(
|
||||
payload: dict[str, Any],
|
||||
sensitive_fields: set[str],
|
||||
@@ -346,10 +321,8 @@ def get_masked_fields(
|
||||
for match in jsonpath_expr.find(payload):
|
||||
if match.value == PASSWORD_MASK:
|
||||
# Using `match.full_path` instead of json_path to account
|
||||
# for wildcards. Render the path explicitly so the output is
|
||||
# stable across jsonpath-ng versions (newer releases wrap
|
||||
# `Child` paths in parentheses when stringified).
|
||||
masked.append(f"$.{_render_jsonpath(match.full_path)}")
|
||||
# for wildcards
|
||||
masked.append(f"$.{match.full_path}")
|
||||
return masked
|
||||
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
@mock.patch("smtplib.SMTP")
|
||||
def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
|
||||
current_app.config["SMTP_SSL"] = True
|
||||
current_app.config["SMTP_SSL_SERVER_AUTH"] = False
|
||||
mock_smtp.return_value = mock.Mock()
|
||||
mock_smtp_ssl.return_value = mock.Mock()
|
||||
utils.send_mime_email(
|
||||
|
||||
@@ -312,3 +312,119 @@ def test_full_setting(
|
||||
assert dttm_col.is_dttm
|
||||
assert dttm_col.python_date_format == "epoch_s"
|
||||
assert dttm_col.expression == "CAST(dttm as INTEGER)"
|
||||
|
||||
|
||||
def test_smtp_ssl_server_auth_defaults_to_true() -> None:
|
||||
"""
|
||||
The shipped default for SMTP_SSL_SERVER_AUTH validates the SMTP server's
|
||||
TLS certificate. Operators can still opt out by overriding it to False.
|
||||
"""
|
||||
from superset import config
|
||||
|
||||
assert config.SMTP_SSL_SERVER_AUTH is True
|
||||
|
||||
|
||||
def _smtp_config(**overrides: Any) -> dict[str, Any]:
|
||||
config = {
|
||||
"SMTP_HOST": "localhost",
|
||||
"SMTP_PORT": 25,
|
||||
"SMTP_USER": "",
|
||||
"SMTP_PASSWORD": "",
|
||||
"SMTP_STARTTLS": False,
|
||||
"SMTP_SSL": False,
|
||||
"SMTP_SSL_SERVER_AUTH": True,
|
||||
}
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
|
||||
def test_send_mime_email_ssl_server_auth_passes_context(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
With SMTP_SSL and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
|
||||
default SSL context and threads it through to ``smtplib.SMTP_SSL`` so the
|
||||
server certificate is validated.
|
||||
"""
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from superset.utils import core as utils
|
||||
|
||||
create_default_context = mocker.patch(
|
||||
"superset.utils.core.ssl.create_default_context"
|
||||
)
|
||||
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
|
||||
smtp = mocker.patch("smtplib.SMTP")
|
||||
|
||||
utils.send_mime_email(
|
||||
"from",
|
||||
["to"],
|
||||
MIMEMultipart(),
|
||||
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=True),
|
||||
dryrun=False,
|
||||
)
|
||||
|
||||
create_default_context.assert_called_once_with()
|
||||
assert not smtp.called
|
||||
smtp_ssl.assert_called_once_with(
|
||||
"localhost", 25, context=create_default_context.return_value
|
||||
)
|
||||
|
||||
|
||||
def test_send_mime_email_starttls_server_auth_passes_context(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
With STARTTLS and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
|
||||
default SSL context and threads it through to ``starttls`` so the server
|
||||
certificate is validated.
|
||||
"""
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from superset.utils import core as utils
|
||||
|
||||
create_default_context = mocker.patch(
|
||||
"superset.utils.core.ssl.create_default_context"
|
||||
)
|
||||
smtp = mocker.patch("smtplib.SMTP")
|
||||
|
||||
utils.send_mime_email(
|
||||
"from",
|
||||
["to"],
|
||||
MIMEMultipart(),
|
||||
_smtp_config(SMTP_STARTTLS=True, SMTP_SSL_SERVER_AUTH=True),
|
||||
dryrun=False,
|
||||
)
|
||||
|
||||
create_default_context.assert_called_once_with()
|
||||
smtp.return_value.starttls.assert_called_once_with(
|
||||
context=create_default_context.return_value
|
||||
)
|
||||
|
||||
|
||||
def test_send_mime_email_server_auth_disabled_skips_context(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
When SMTP_SSL_SERVER_AUTH is disabled no SSL context is built and ``None`` is
|
||||
passed through, preserving the opt-out (certificate validation skipped).
|
||||
"""
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from superset.utils import core as utils
|
||||
|
||||
create_default_context = mocker.patch(
|
||||
"superset.utils.core.ssl.create_default_context"
|
||||
)
|
||||
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
|
||||
|
||||
utils.send_mime_email(
|
||||
"from",
|
||||
["to"],
|
||||
MIMEMultipart(),
|
||||
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=False),
|
||||
dryrun=False,
|
||||
)
|
||||
|
||||
assert not create_default_context.called
|
||||
smtp_ssl.assert_called_once_with("localhost", 25, context=None)
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
# 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 the duplicate_dashboard MCP tool.
|
||||
|
||||
Follows the same pattern used in test_add_chart_to_existing_dashboard.py:
|
||||
- Tests run through the async MCP Client (not direct function calls)
|
||||
- Patches applied at source locations (superset.daos.dashboard.*,
|
||||
superset.commands.dashboard.copy.*)
|
||||
- auth is mocked via the autouse mock_auth fixture
|
||||
|
||||
Covers:
|
||||
- Duplicate referencing the same charts (duplicate_slices=False, default)
|
||||
- Duplicate with deep-copied charts (duplicate_slices=True)
|
||||
- Source dashboard not found
|
||||
- Source dashboard access denied / copy forbidden
|
||||
- Title sanitization (XSS stripped, XSS-only title rejected)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.utils import json
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server() -> object:
|
||||
"""Return the FastMCP app instance for use in MCP client tests."""
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_auth():
|
||||
"""Mock authentication for all tests."""
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SOURCE_POSITIONS = {
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
|
||||
"GRID_ID": {
|
||||
"children": ["CHART-10"],
|
||||
"id": "GRID_ID",
|
||||
"parents": ["ROOT_ID"],
|
||||
"type": "GRID",
|
||||
},
|
||||
"CHART-10": {
|
||||
"children": [],
|
||||
"id": "CHART-10",
|
||||
"meta": {"chartId": 10, "height": 50, "width": 4},
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
"type": "CHART",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _mock_chart(id: int = 10, slice_name: str = "Test Chart") -> Mock:
|
||||
"""Create a minimal mock Slice object with the given ID and name."""
|
||||
chart = Mock()
|
||||
chart.id = id
|
||||
chart.slice_name = slice_name
|
||||
chart.uuid = f"chart-uuid-{id}"
|
||||
chart.tags = []
|
||||
chart.owners = []
|
||||
chart.viz_type = "table"
|
||||
chart.datasource_name = None
|
||||
chart.description = None
|
||||
return chart
|
||||
|
||||
|
||||
def _mock_dashboard(
|
||||
id: int = 1,
|
||||
title: str = "Sales Dashboard",
|
||||
slices: list[Mock] | None = None,
|
||||
json_metadata: str | None = None,
|
||||
position_json: str | None = None,
|
||||
) -> Mock:
|
||||
"""Create a minimal mock Dashboard object."""
|
||||
dashboard = Mock()
|
||||
dashboard.id = id
|
||||
dashboard.dashboard_title = title
|
||||
dashboard.slug = f"test-dashboard-{id}"
|
||||
dashboard.description = None
|
||||
dashboard.published = True
|
||||
dashboard.created_on = None
|
||||
dashboard.changed_on = None
|
||||
dashboard.uuid = f"dashboard-uuid-{id}"
|
||||
dashboard.slices = slices or []
|
||||
dashboard.owners = []
|
||||
dashboard.tags = []
|
||||
dashboard.roles = []
|
||||
dashboard.position_json = position_json or json.dumps(SOURCE_POSITIONS)
|
||||
dashboard.json_metadata = json_metadata
|
||||
dashboard.css = None
|
||||
dashboard.certified_by = None
|
||||
dashboard.certification_details = None
|
||||
dashboard.is_managed_externally = False
|
||||
dashboard.external_url = None
|
||||
return dashboard
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@patch("superset.commands.dashboard.copy.CopyDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_referencing_same_charts(
|
||||
mock_get_by_id_or_slug: Mock,
|
||||
mock_copy_cmd_cls: Mock,
|
||||
mock_find_by_id: Mock,
|
||||
mcp_server: object,
|
||||
) -> None:
|
||||
"""Happy path: the copy references the same charts (default)."""
|
||||
chart = _mock_chart(id=10)
|
||||
source = _mock_dashboard(
|
||||
id=1,
|
||||
slices=[chart],
|
||||
json_metadata=json.dumps({"color_scheme": "supersetColors"}),
|
||||
)
|
||||
new_dashboard = _mock_dashboard(id=2, title="Staging Copy", slices=[chart])
|
||||
|
||||
mock_get_by_id_or_slug.return_value = source
|
||||
mock_copy_cmd_cls.return_value.run.return_value = new_dashboard
|
||||
mock_find_by_id.return_value = new_dashboard
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"duplicate_dashboard",
|
||||
{"request": {"dashboard_id": 1, "dashboard_title": "Staging Copy"}},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["error"] is None
|
||||
assert content["duplicated_slices"] is False
|
||||
assert content["dashboard"]["id"] == 2
|
||||
assert content["dashboard"]["dashboard_title"] == "Staging Copy"
|
||||
assert "/superset/dashboard/2/" in content["dashboard_url"]
|
||||
|
||||
# The copy data contract must mirror what the frontend "Save as" sends:
|
||||
# required json_metadata containing the source's metadata + positions.
|
||||
mock_copy_cmd_cls.assert_called_once()
|
||||
cmd_source, cmd_data = mock_copy_cmd_cls.call_args.args
|
||||
assert cmd_source is source
|
||||
assert cmd_data["dashboard_title"] == "Staging Copy"
|
||||
assert cmd_data["duplicate_slices"] is False
|
||||
assert cmd_data["css"] is None
|
||||
sent_metadata = json.loads(cmd_data["json_metadata"])
|
||||
assert sent_metadata["color_scheme"] == "supersetColors"
|
||||
assert sent_metadata["positions"] == SOURCE_POSITIONS
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@patch("superset.commands.dashboard.copy.CopyDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_with_duplicate_slices(
|
||||
mock_get_by_id_or_slug: Mock,
|
||||
mock_copy_cmd_cls: Mock,
|
||||
mock_find_by_id: Mock,
|
||||
mcp_server: object,
|
||||
) -> None:
|
||||
"""duplicate_slices=True is forwarded to the command and reported back."""
|
||||
source = _mock_dashboard(id=1, slices=[_mock_chart(id=10)])
|
||||
new_chart = _mock_chart(id=20)
|
||||
new_dashboard = _mock_dashboard(id=3, title="Regional Variant", slices=[new_chart])
|
||||
|
||||
mock_get_by_id_or_slug.return_value = source
|
||||
mock_copy_cmd_cls.return_value.run.return_value = new_dashboard
|
||||
mock_find_by_id.return_value = new_dashboard
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"duplicate_dashboard",
|
||||
{
|
||||
"request": {
|
||||
"dashboard_id": 1,
|
||||
"dashboard_title": "Regional Variant",
|
||||
"duplicate_slices": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["error"] is None
|
||||
assert content["duplicated_slices"] is True
|
||||
assert content["dashboard"]["id"] == 3
|
||||
assert "/superset/dashboard/3/" in content["dashboard_url"]
|
||||
|
||||
_, cmd_data = mock_copy_cmd_cls.call_args.args
|
||||
assert cmd_data["duplicate_slices"] is True
|
||||
# positions must always be present in json_metadata: the DAO reads it to
|
||||
# remap chart IDs when duplicating slices.
|
||||
assert "positions" in json.loads(cmd_data["json_metadata"])
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_not_found(
|
||||
mock_get_by_id_or_slug: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""Returns a clear error when the source dashboard does not exist."""
|
||||
from superset.commands.dashboard.exceptions import DashboardNotFoundError
|
||||
|
||||
mock_get_by_id_or_slug.side_effect = DashboardNotFoundError()
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"duplicate_dashboard",
|
||||
{"request": {"dashboard_id": 999, "dashboard_title": "Copy"}},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["dashboard"] is None
|
||||
assert content["dashboard_url"] is None
|
||||
assert "not found" in (content["error"] or "").lower()
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_access_denied(
|
||||
mock_get_by_id_or_slug: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""Returns an error when the user cannot access the source dashboard."""
|
||||
from superset.commands.dashboard.exceptions import DashboardAccessDeniedError
|
||||
|
||||
mock_get_by_id_or_slug.side_effect = DashboardAccessDeniedError()
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"duplicate_dashboard",
|
||||
{"request": {"dashboard_id": 1, "dashboard_title": "Copy"}},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["dashboard"] is None
|
||||
assert "access" in (content["error"] or "").lower()
|
||||
|
||||
|
||||
@patch("superset.commands.dashboard.copy.CopyDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_forbidden(
|
||||
mock_get_by_id_or_slug: Mock,
|
||||
mock_copy_cmd_cls: Mock,
|
||||
mcp_server: object,
|
||||
) -> None:
|
||||
"""Returns an error when the copy command raises DashboardForbiddenError
|
||||
(e.g. DASHBOARD_RBAC requires ownership of the source)."""
|
||||
from superset.commands.dashboard.exceptions import DashboardForbiddenError
|
||||
|
||||
mock_get_by_id_or_slug.return_value = _mock_dashboard(id=1)
|
||||
mock_copy_cmd_cls.return_value.run.side_effect = DashboardForbiddenError()
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"duplicate_dashboard",
|
||||
{"request": {"dashboard_id": 1, "dashboard_title": "Copy"}},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["dashboard"] is None
|
||||
assert "permission" in (content["error"] or "").lower()
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@patch("superset.commands.dashboard.copy.CopyDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@pytest.mark.asyncio
|
||||
async def test_title_xss_is_sanitized(
|
||||
mock_get_by_id_or_slug: Mock,
|
||||
mock_copy_cmd_cls: Mock,
|
||||
mock_find_by_id: Mock,
|
||||
mcp_server: object,
|
||||
) -> None:
|
||||
"""HTML/script content is stripped from the title and a warning surfaced."""
|
||||
source = _mock_dashboard(id=1)
|
||||
new_dashboard = _mock_dashboard(id=4, title="Regional Copy")
|
||||
|
||||
mock_get_by_id_or_slug.return_value = source
|
||||
mock_copy_cmd_cls.return_value.run.return_value = new_dashboard
|
||||
mock_find_by_id.return_value = new_dashboard
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"duplicate_dashboard",
|
||||
{
|
||||
"request": {
|
||||
"dashboard_id": 1,
|
||||
"dashboard_title": "<script>alert('x')</script>Regional Copy",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["error"] is None
|
||||
# The sanitized title — not the raw payload — is sent to the command.
|
||||
_, cmd_data = mock_copy_cmd_cls.call_args.args
|
||||
assert cmd_data["dashboard_title"] == "Regional Copy"
|
||||
assert content["warnings"], "expected a sanitization warning"
|
||||
|
||||
|
||||
def test_title_xss_only_rejected_by_schema() -> None:
|
||||
"""A title that sanitizes to nothing is rejected with a clear error."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
from superset.mcp_service.dashboard.schemas import DuplicateDashboardRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
DuplicateDashboardRequest(
|
||||
dashboard_id=1, dashboard_title="<script>alert(1)</script>"
|
||||
)
|
||||
|
||||
|
||||
def test_empty_title_rejected_by_schema() -> None:
|
||||
"""An empty title is rejected at the schema layer."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
from superset.mcp_service.dashboard.schemas import DuplicateDashboardRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
DuplicateDashboardRequest(dashboard_id=1, dashboard_title="")
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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.
|
||||
|
||||
from superset.utils.core import split
|
||||
|
||||
|
||||
def test_split_empty_string():
|
||||
assert list(split("")) == [""]
|
||||
|
||||
|
||||
def test_split_leading_delimiter():
|
||||
assert list(split(" a")) == [
|
||||
"",
|
||||
"a",
|
||||
]
|
||||
|
||||
|
||||
def test_split_trailing_delimiter():
|
||||
assert list(split("a ")) == [
|
||||
"a",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def test_split_only_delimiter():
|
||||
assert list(split(" ")) == [
|
||||
"",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def test_split_nested_parentheses():
|
||||
assert list(
|
||||
split(
|
||||
"a,(b,(c,d))",
|
||||
delimiter=",",
|
||||
)
|
||||
) == [
|
||||
"a",
|
||||
"(b,(c,d))",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_separator_found():
|
||||
assert list(split("a b")) == [
|
||||
"a",
|
||||
"b",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_separator_not_found():
|
||||
assert list(split("ab")) == [
|
||||
"ab",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_parentheses():
|
||||
assert list(split("(a b)")) == [
|
||||
"(a b)",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_escaped_quote():
|
||||
assert list(split(r'"a\"b c" d')) == [
|
||||
r'"a\"b c"',
|
||||
"d",
|
||||
]
|
||||
Reference in New Issue
Block a user