Compare commits

..

20 Commits

Author SHA1 Message Date
Claude Code
d544bff071 fix(chart): keep query-context updates bound to the chart's datasource
On the query-context-only update path UpdateChartCommand intentionally
skips the ownership check so report and alert workers can refresh a
chart's cached payload. Validate that the submitted query context still
targets the chart's own datasource (id and type) before saving, so a
cached payload cannot be repointed at an unrelated datasource. Payloads
without a parseable datasource fall back to the chart's datasource at
execution time and are left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:29:39 -07:00
Dylan Cavalcante
f79a88c685 test(core): add unit tests for split function (#40819)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:12:35 -07:00
dependabot[bot]
b1d965932d chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.60.0 to 8.60.1 in /superset-websocket (#40888)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:46:38 -07:00
dependabot[bot]
7d046340dc chore(deps): bump ag-grid-react from 35.3.0 to 35.3.1 in /superset-frontend/packages/superset-ui-core (#40924)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:46:24 -07:00
dependabot[bot]
aa872cd0a1 chore(deps): bump dompurify from 3.4.9 to 3.4.8 in /superset-frontend/packages/superset-ui-core (#40938)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:45:33 -07:00
dependabot[bot]
b2c5a1ecb3 chore(deps): bump jsonpath-ng from 1.7.0 to 1.8.0 (#40940)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:45:21 -07:00
dependabot[bot]
6cd9bdee0b chore(deps-dev): bump @formatjs/intl-durationformat from 0.10.3 to 0.10.13 in /superset-frontend (#40925)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:44:40 -07:00
dependabot[bot]
a8a1d9c17d chore(deps): bump morgan from 1.10.1 to 1.11.0 in /superset-websocket/utils/client-ws-app (#40921)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:43:33 -07:00
dependabot[bot]
97058d2cf0 chore(deps): bump fuse.js from 7.3.0 to 7.4.1 in /superset-frontend (#40922)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:43:19 -07:00
dependabot[bot]
ef57409209 chore(deps): bump ag-grid-community from 35.3.0 to 35.3.1 in /superset-frontend/packages/superset-ui-core (#40923)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:43:06 -07:00
dependabot[bot]
5f06e66cf1 chore(deps): bump @deck.gl/mapbox from 9.3.2 to 9.3.3 in /superset-frontend (#40927)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:42:22 -07:00
dependabot[bot]
11af932099 chore(deps): bump dompurify from 3.4.7 to 3.4.8 in /superset-frontend/plugins/legacy-preset-chart-nvd3 (#40937)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:42:06 -07:00
dependabot[bot]
c9c05d8d0a chore(deps-dev): update thrift requirement from <1.0.0,>=0.14.1 to >=0.23.0,<1.0.0 (#40942)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:36:51 -07:00
dependabot[bot]
0f59705806 chore(deps): bump wtforms from 3.2.1 to 3.2.2 (#40943)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:36:26 -07:00
dependabot[bot]
320965612d chore(deps-dev): update clickhouse-connect requirement from <2.0,>=0.13.0 to >=1.1.1,<2.0 (#40944)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:36:03 -07:00
dependabot[bot]
c3df60c12b chore(deps): bump selenium from 4.32.0 to 4.44.0 (#40945)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:34:01 -07:00
dependabot[bot]
4f69949c10 chore(deps-dev): bump eslint-plugin-storybook from 10.4.1 to 10.4.2 in /superset-frontend (#40949)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:31:47 -07:00
bogdanmoale
3380496e9f feat(i18n): add Romanian (ro) translations (#36712)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-10 12:25:42 -07:00
Michael S. Molina
248ccadecd fix(extensions): load extensions async to avoid blocking initial page render (#40915)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:23:38 -03:00
Joe Li
cc5a3ddd05 test(dashboard-filter): RTL coverage for horizontal filter bar (#40782)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 10:53:56 -07:00
41 changed files with 17474 additions and 483 deletions

View File

@@ -70,18 +70,6 @@ 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.

View File

@@ -64,7 +64,7 @@ dependencies = [
"holidays>=0.45, <1",
"humanize",
"isodate",
"jsonpath-ng>=1.6.1, <2",
"jsonpath-ng>=1.8.0, <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.14.0, <5.0",
"selenium>=4.44.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>=2.3.3, <4",
"wtforms>=3.2.2, <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>=0.13.0, <2.0"]
clickhouse = ["clickhouse-connect>=1.1.1, <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.14.1, <1.0.0",
"thrift>=0.23.0, <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.14.1, <1",
"thrift>=0.23.0, <1",
]
tdengine = [
"taospy>=2.7.21",

View File

@@ -50,7 +50,7 @@ cattrs==25.1.1
# via requests-cache
celery==5.5.2
# via apache-superset (pyproject.toml)
certifi==2025.6.15
certifi==2026.5.20
# via
# requests
# selenium
@@ -194,7 +194,7 @@ jinja2==3.1.6
# via
# flask
# flask-babel
jsonpath-ng==1.7.0
jsonpath-ng==1.8.0
# via apache-superset (pyproject.toml)
jsonschema==4.23.0
# via
@@ -286,8 +286,6 @@ 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
@@ -380,7 +378,7 @@ rpds-py==0.25.0
# referencing
rsa==4.9.1
# via google-auth
selenium==4.32.0
selenium==4.44.0
# via apache-superset (pyproject.toml)
setuptools==80.9.0
# via -r requirements/base.in
@@ -423,7 +421,7 @@ sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.10.0
# via apache-superset (pyproject.toml)
trio==0.30.0
trio==0.33.0
# via
# selenium
# trio-websocket
@@ -480,7 +478,7 @@ wrapt==1.17.2
# via deprecated
wsproto==1.2.0
# via trio-websocket
wtforms==3.2.1
wtforms==3.2.2
# via
# apache-superset (pyproject.toml)
# flask-appbuilder

View File

@@ -112,7 +112,7 @@ celery==5.5.2
# via
# -c requirements/base-constraint.txt
# apache-superset
certifi==2025.6.15
certifi==2026.5.20
# via
# -c requirements/base-constraint.txt
# httpcore
@@ -471,7 +471,7 @@ jmespath==1.1.0
# via
# boto3
# botocore
jsonpath-ng==1.7.0
jsonpath-ng==1.8.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -674,10 +674,6 @@ 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
@@ -925,7 +921,7 @@ s3transfer==0.16.0
# via boto3
secretstorage==3.5.0
# via keyring
selenium==4.32.0
selenium==4.44.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -1023,7 +1019,7 @@ tqdm==4.67.1
# prophet
trino==0.330.0
# via apache-superset
trio==0.30.0
trio==0.33.0
# via
# -c requirements/base-constraint.txt
# selenium
@@ -1125,7 +1121,7 @@ wsproto==1.2.0
# via
# -c requirements/base-constraint.txt
# trio-websocket
wtforms==3.2.1
wtforms==3.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -48,6 +48,7 @@ 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 }],

View File

@@ -95,7 +95,7 @@
"echarts": "^5.6.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.3.0",
"fuse.js": "^7.4.1",
"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.3",
"@formatjs/intl-durationformat": "^0.10.13",
"@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.1",
"@storybook/addon-links": "10.4.1",
"@storybook/react-webpack5": "10.4.1",
"@storybook/addon-docs": "10.4.2",
"@storybook/addon-links": "10.4.2",
"@storybook/react-webpack5": "10.4.2",
"@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.1",
"eslint-plugin-storybook": "10.4.2",
"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.1",
"storybook": "10.4.2",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",
@@ -3939,50 +3939,38 @@
}
},
"node_modules/@formatjs/bigdecimal": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.0.tgz",
"integrity": "sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==",
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.5.tgz",
"integrity": "sha512-2XTKNrZRaCUyXK2976wfutqxMBuPO/S/zbJnQdysLI2Zy5mWPVNVEkE6tsTcSVWSE7DgO88t8DtBy+uf3I8bxg==",
"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.1",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.1.tgz",
"integrity": "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.5.tgz",
"integrity": "sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/intl-durationformat": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.3.tgz",
"integrity": "sha512-xRS3GaOlsQLwz0n56SvaddwEnl2NLPKBvYg2M32ak/27dodmVxFJz3j7Nqj7EwKyHTu3f/e+BeoKPrIDUSXTuQ==",
"version": "0.10.13",
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.13.tgz",
"integrity": "sha512-A1dBcOh1YrcRf/AbmZHFVXgIYkpAaFgyGaYavO/KutbqEXY3HI63o2E1ctmxmllfg3qn3TZGtZux42EFwHNTbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/ecma402-abstract": "3.2.0",
"@formatjs/intl-localematcher": "0.8.2"
"@formatjs/bigdecimal": "0.2.5",
"@formatjs/intl-localematcher": "0.8.9"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.2.tgz",
"integrity": "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.1"
"@formatjs/fast-memoize": "3.1.5"
}
},
"node_modules/@gar/promise-retry": {
@@ -9696,16 +9684,16 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"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==",
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.2.tgz",
"integrity": "sha512-CtW1O4xSKZPNtpWgpfp4yB/x4pj/of+3MvlEDfErSlr3Hp3QmEa2pCLaecR08H5LJqJFlt1PtG0UrIynTvgW9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.4.1",
"@storybook/csf-plugin": "10.4.2",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.4.1",
"@storybook/react-dom-shim": "10.4.2",
"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"
@@ -9716,7 +9704,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.4.1"
"storybook": "^10.4.2"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -9724,45 +9712,10 @@
}
}
},
"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.1",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.4.1.tgz",
"integrity": "sha512-h/5D23GwMuHA55sB7XDyhByF9psF7UFmaQOn72pjNAarew5eOpue5A+jXk3AKEYokHbvgQaoz+FrvWo9GEfSKQ==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9775,7 +9728,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.1"
"storybook": "^10.4.2"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -9787,13 +9740,13 @@
}
},
"node_modules/@storybook/builder-webpack5": {
"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==",
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.4.2.tgz",
"integrity": "sha512-nhmV0+nThCgy1y5742SS7c4vJrd5/1KfCXCNfsJ1v4Rkq7NIQnUhEIBwkSaY63lqH7FRHlFxIjwGS63veiCJuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.4.1",
"@storybook/core-webpack": "10.4.2",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"cjs-module-lexer": "^1.2.3",
"css-loader": "^7.1.2",
@@ -9814,7 +9767,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.4.1"
"storybook": "^10.4.2"
},
"peerDependenciesMeta": {
"typescript": {
@@ -9823,9 +9776,9 @@
}
},
"node_modules/@storybook/core-webpack": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.1.tgz",
"integrity": "sha512-Wert/4ou5WRl8WYWWS8bBW7Lxa/ASMEuQ3EVuG3SITAtPNvKDKqTFBjZLx9eJSefkX6fJ3yG85FFUOPsv6GemQ==",
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.4.2.tgz",
"integrity": "sha512-qnYKMruU8lvI4yaq2PA9Gmxjrc7EZ3DRBI/cVKwEgOIREoxzr1F1IE7t7+325k9Phylue7E5rD3A7yjxeEKUyw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9836,7 +9789,42 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.4.1"
"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
}
}
},
"node_modules/@storybook/global": {
@@ -9858,13 +9846,13 @@
}
},
"node_modules/@storybook/preset-react-webpack": {
"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==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.4.1",
"@storybook/core-webpack": "10.4.2",
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
"@types/semver": "^7.7.1",
"magic-string": "^0.30.5",
@@ -9881,7 +9869,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.1"
"storybook": "^10.4.2"
},
"peerDependenciesMeta": {
"typescript": {
@@ -9890,14 +9878,14 @@
}
},
"node_modules/@storybook/react": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.1.tgz",
"integrity": "sha512-WuYz4NaUk4gmFAMliSpCbV8w6jP5OY9juBfw1huwzu2S/k5FhnVXwmrUaL0fmf3Bq/7NgkzmBBbZr6I6LuHayQ==",
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.2.tgz",
"integrity": "sha512-NfEH3CrdCAgUV4Z7SPN3Iw6nofcueqtRj8iHuo77GNjz0qSfuVi9iS7a8o7x7QFSeIBZwS0Jv3CgmhN8qvoLjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.4.1",
"@storybook/react-dom-shim": "10.4.2",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@@ -9910,7 +9898,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.1",
"storybook": "^10.4.2",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -9990,9 +9978,9 @@
}
},
"node_modules/@storybook/react-dom-shim": {
"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==",
"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==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10004,7 +9992,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.1"
"storybook": "^10.4.2"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10016,15 +10004,15 @@
}
},
"node_modules/@storybook/react-webpack5": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.4.1.tgz",
"integrity": "sha512-2jF231DrEk70I8+wVakCnKtpweGFNfxdaov883Rve0TFvhxZs42Y9PpKzSf4rusvSrWc9jdWuJ2k7ERbS50MLg==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/builder-webpack5": "10.4.1",
"@storybook/preset-react-webpack": "10.4.1",
"@storybook/react": "10.4.1"
"@storybook/builder-webpack5": "10.4.2",
"@storybook/preset-react-webpack": "10.4.2",
"@storybook/react": "10.4.2"
},
"funding": {
"type": "opencollective",
@@ -10033,7 +10021,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.1",
"storybook": "^10.4.2",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -19421,9 +19409,9 @@
}
},
"node_modules/eslint-plugin-storybook": {
"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==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -19431,7 +19419,7 @@
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "^10.4.1"
"storybook": "^10.4.2"
}
},
"node_modules/eslint-plugin-testing-library": {
@@ -20933,9 +20921,9 @@
}
},
"node_modules/fuse.js": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.0.tgz",
"integrity": "sha512-3UqmoSFwzX1sNB1YSk+Co0EdH29XCW2p9g48OAiy93cjKqzuABsqw2VIgSN3CmsT/wo6pIJ3F0Jxeiiby8rhIQ==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.1.tgz",
"integrity": "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
@@ -39048,9 +39036,9 @@
}
},
"node_modules/storybook": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.1.tgz",
"integrity": "sha512-V1Zd2e+gBFufqAQVZ1JR8KLqALsEZ3JYSBnWwQbKa6zCfWWanR6AFMyuOkLt2gZOgGp3h2Riuz88pGNVTQSG0A==",
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.2.tgz",
"integrity": "sha512-5Ax5vbHxFgMBGGhQDm75Rrumm/HZC4ICFhMcJaM0UlqnC/4FKj/IaZtImZFupknyiiyUEcWHPQFA2kX3/VSv1A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -45375,7 +45363,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.2",
"@deck.gl/mapbox": "^9.3.3",
"@deck.gl/mesh-layers": "~9.2.5",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
@@ -45423,9 +45411,9 @@
}
},
"plugins/preset-chart-deckgl/node_modules/@deck.gl/mapbox": {
"version": "9.3.2",
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.2.tgz",
"integrity": "sha512-+T9pJwsOXwjUxyGN6oiBMfIs28VtDIG1V1Rqz4qqn4TjjNEFFw+xO0olJIg8FO5IAqw2OtePdsrMj0tX8tHdGQ==",
"version": "9.3.3",
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.3.tgz",
"integrity": "sha512-aUPqrwF6wkx+EtvKA3SaiK+UROMnZSmgEJWZ1qSKFSiH//kPuo5imbtXyan8sGhOet7NjnfEwJqFA3EBk7zDLA==",
"license": "MIT",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0"

View File

@@ -178,7 +178,7 @@
"echarts": "^5.6.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.3.0",
"fuse.js": "^7.4.1",
"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.3",
"@formatjs/intl-durationformat": "^0.10.13",
"@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.1",
"@storybook/addon-links": "10.4.1",
"@storybook/react-webpack5": "10.4.1",
"@storybook/addon-docs": "10.4.2",
"@storybook/addon-links": "10.4.2",
"@storybook/react-webpack5": "10.4.2",
"@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.1",
"eslint-plugin-storybook": "10.4.2",
"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.1",
"storybook": "10.4.2",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",

View File

@@ -37,7 +37,7 @@
* ```
*/
import { Disposable } from '../common';
import { Disposable, Event } from '../common';
/**
* Represents a menu item that links a view to a command.
@@ -102,3 +102,37 @@ 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>;

View File

@@ -36,7 +36,7 @@
*/
import { ReactElement } from 'react';
import { Disposable } from '../common';
import { Disposable, Event } from '../common';
/**
* Represents a contributed view in the application.
@@ -88,3 +88,33 @@ 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>;

View File

@@ -43,7 +43,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.7",
"dompurify": "^3.4.8",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",

View File

@@ -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');

View File

@@ -34,7 +34,7 @@
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.4.7",
"dompurify": "^3.4.8",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},

View File

@@ -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.2",
"@deck.gl/mapbox": "~9.3.3",
"@deck.gl/mesh-layers": "~9.2.5",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",

View File

@@ -19,6 +19,7 @@
import {
DataMaskStateWithId,
ExtraFormData,
Filter,
NativeFiltersState,
NativeFilterType,
} from '@superset-ui/core';
@@ -458,6 +459,25 @@ 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,

View File

@@ -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 { views } from 'src/core';
import { useViews } 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 = views.getViews(ViewLocations.sqllab.rightSidebar) || [];
const viewItems = useViews(ViewLocations.sqllab.rightSidebar) || [];
return (
<StyledContainer>

View File

@@ -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 { views } from 'src/core';
import { useViews } 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 = views.getViews(ViewLocations.sqllab.panels) || [];
const viewItems = useViews(ViewLocations.sqllab.panels) || [];
const { offline, tables } = useSelector(
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
offline,

View File

@@ -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 { views } from 'src/core';
import { useViews } 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 = views.getViews(ViewLocations.sqllab.statusBar) || [];
const statusBarViews = useViews(ViewLocations.sqllab.statusBar) || [];
return (
<>

View File

@@ -17,11 +17,12 @@
* 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, menus } from 'src/core';
import { commands } from 'src/core';
export interface PanelToolbarProps {
viewId: string;
@@ -35,7 +36,7 @@ const PanelToolbar = ({
defaultSecondaryActions,
}: PanelToolbarProps) => {
const theme = useTheme();
const menu = menus.getMenu(viewId);
const menu = useMenu(viewId);
const primaryItems = menu?.primary || [];
const secondaryItems = menu?.secondary || [];

View File

@@ -39,8 +39,7 @@ jest.mock('./EditorProviders', () => ({
getInstance: () => ({
getProvider: jest.fn().mockReturnValue(undefined),
hasProvider: jest.fn().mockReturnValue(false),
onDidRegister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
onDidUnregister: jest.fn().mockReturnValue({ dispose: jest.fn() }),
subscribe: jest.fn().mockReturnValue(() => {}),
}),
},
}));

View File

@@ -26,13 +26,12 @@
* back to the default Ace editor.
*/
import { useState, useEffect, forwardRef } from 'react';
import { useSyncExternalStore, 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;
@@ -42,49 +41,6 @@ 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.
*
@@ -106,7 +62,12 @@ const useEditorProvider = (language: EditorLanguage) => {
const EditorHost = forwardRef<EditorHandle, EditorHostProps>((props, ref) => {
const { language } = props;
const theme = useTheme();
const provider = useEditorProvider(language);
const manager = EditorProviders.getInstance();
const provider = useSyncExternalStore(
manager.subscribe,
() => manager.getProvider(language),
() => undefined,
);
// Merge theme into props
const propsWithTheme = { ...props, theme };

View File

@@ -93,6 +93,17 @@ 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
@@ -145,6 +156,7 @@ class EditorProviders {
// Fire registration event
this.registerEmitter.fire({ editor });
this.syncListeners.forEach(l => l());
// Return disposable for cleanup
return new Disposable(() => {
@@ -176,6 +188,7 @@ class EditorProviders {
// Fire unregistration event
this.unregisterEmitter.fire({ editor });
this.syncListeners.forEach(l => l());
}
/**
@@ -234,6 +247,7 @@ class EditorProviders {
public reset(): void {
this.providers.clear();
this.languageToProvider.clear();
this.syncListeners.clear();
}
}

View File

@@ -24,6 +24,7 @@
* 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';
@@ -109,6 +110,23 @@ 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.
*/

View File

@@ -24,11 +24,14 @@
* 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;
@@ -38,6 +41,27 @@ 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,
@@ -45,11 +69,13 @@ 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 });
});
};
@@ -77,7 +103,34 @@ 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,
};

View File

@@ -24,13 +24,15 @@
* Extensions register views as side effects at import time.
*/
import React, { ReactElement } from 'react';
import React, { ReactElement, useSyncExternalStore } 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,
@@ -39,6 +41,27 @@ 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,
@@ -51,10 +74,12 @@ 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 });
});
};
@@ -77,7 +102,35 @@ 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,
};

View File

@@ -960,3 +960,92 @@ 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();
});

View File

@@ -0,0 +1,305 @@
/**
* 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));
});
});

View File

@@ -16,9 +16,26 @@
* specific language governing permissions and limitations
* under the License.
*/
import { NativeFilterType } from '@superset-ui/core';
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 { 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,
@@ -32,7 +49,7 @@ const defaultProps = {
onPendingCustomizationDataMaskChange: jest.fn(),
};
const renderWrapper = (overrideProps?: Record<string, any>) =>
const renderWrapper = (overrideProps?: Partial<HorizontalBarProps>) =>
waitFor(() =>
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
useRedux: true,
@@ -60,11 +77,13 @@ 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(
@@ -92,3 +111,133 @@ 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();
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
@@ -52,20 +52,12 @@ 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 (initialized) return;
if (!userId) {
// No user logged in — nothing to initialize
setInitialized(true);
return;
}
if (userId == null) return;
// Provide the implementations for @apache-superset/core
window.superset = {
@@ -80,19 +72,10 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
views,
};
const setup = async () => {
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
await ExtensionsLoader.getInstance().initializeExtensions();
}
setInitialized(true);
};
setup();
}, [initialized, userId]);
if (!initialized) {
return null;
}
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
ExtensionsLoader.getInstance().initializeExtensions();
}
}, [userId]);
return <>{children}</>;
};

View File

@@ -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.0",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",

View File

@@ -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.0",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",

View File

@@ -13,7 +13,7 @@
"express": "~5.2.1",
"http-errors": "~2.0.1",
"jsonwebtoken": "^9.0.3",
"morgan": "~1.10.1",
"morgan": "~1.11.0",
"pug": "~3.0.4"
}
},
@@ -127,17 +127,6 @@
"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",
@@ -455,17 +444,6 @@
"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",
@@ -483,18 +461,6 @@
"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",
@@ -867,18 +833,22 @@
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
"dependencies": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-finished": "~2.4.1",
"on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/morgan/node_modules/debug": {
@@ -928,9 +898,9 @@
}
},
"node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
"integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
"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"
},
@@ -1227,18 +1197,6 @@
"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",
@@ -1509,16 +1467,6 @@
"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": {
@@ -1740,14 +1688,6 @@
"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"
}
}
}
},
@@ -1762,16 +1702,6 @@
"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": {
@@ -2040,14 +1970,14 @@
}
},
"morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
"requires": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-finished": "~2.4.1",
"on-headers": "~1.1.0"
},
"dependencies": {
@@ -2087,9 +2017,9 @@
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
},
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
"integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
"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"
}
@@ -2337,16 +2267,6 @@
"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": {

View File

@@ -11,7 +11,7 @@
"express": "~5.2.1",
"http-errors": "~2.0.1",
"jsonwebtoken": "^9.0.3",
"morgan": "~1.10.1",
"morgan": "~1.11.0",
"pug": "~3.0.4"
}
}

View File

@@ -103,6 +103,19 @@ class DatasourceTypeUpdateRequiredValidationError(ValidationError):
)
class ChartQueryContextDatasourceMismatchValidationError(ValidationError):
"""
Raised when a query-context-only update carries a datasource that does not
match the chart's own datasource.
"""
def __init__(self) -> None:
super().__init__(
_("The query context datasource does not match the chart datasource"),
field_name="query_context",
)
class ChartNotFoundError(CommandException):
message = "Chart not found."

View File

@@ -29,6 +29,7 @@ from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
ChartNotFoundError,
ChartQueryContextDatasourceMismatchValidationError,
ChartUpdateFailedError,
DashboardsForbiddenError,
DashboardsNotFoundValidationError,
@@ -41,6 +42,7 @@ from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType
from superset.utils import json
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -101,6 +103,51 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
if not security_manager.is_owner(dash):
raise DashboardsForbiddenError()
def _validate_query_context_datasource(
self, exceptions: list[ValidationError]
) -> None:
"""
Ensure a query-context-only update keeps the chart's own datasource.
The submitted query context is only verified when it carries a parseable
``datasource`` object; a payload that references a different datasource than
the chart's persisted one is rejected. Payloads without a datasource fall
back to the chart's datasource at execution time and need no check.
"""
if not self._model:
return
raw_query_context = self._properties.get("query_context")
if not raw_query_context:
return
try:
query_context = json.loads(raw_query_context)
except (TypeError, ValueError):
# An unparseable payload cannot be verified or replayed; leave it for
# downstream handling rather than guessing at its intent.
return
datasource = (
query_context.get("datasource") if isinstance(query_context, dict) else None
)
if not isinstance(datasource, dict):
return
try:
ids_match = int(datasource["id"]) == self._model.datasource_id
except (KeyError, TypeError, ValueError):
ids_match = False
datasource_type = datasource.get("type")
types_match = (
datasource_type is None
or str(datasource_type) == self._model.datasource_type
)
if not ids_match or not types_match:
exceptions.append(ChartQueryContextDatasourceMismatchValidationError())
def validate(self) -> None: # noqa: C901
exceptions: list[ValidationError] = []
dashboard_ids = self._properties.get("dashboards")
@@ -134,6 +181,12 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
raise ChartForbiddenError() from ex
except ValidationError as ex:
exceptions.append(ex)
else:
# The query-context-only path skips the ownership check so report and
# alert workers can refresh a chart's cached payload. Keep that payload
# bound to the chart's own datasource so it cannot be repointed at an
# unrelated one.
self._validate_query_context_datasource(exceptions)
# validate tags
try:

View File

@@ -459,6 +459,7 @@ 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.
@@ -1684,14 +1685,9 @@ 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.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
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
# default system root CA certificates.
SMTP_SSL_SERVER_AUTH = False
ENABLE_CHUNK_ENCODING = False
# Whether to bump the logging level to ERROR on the flask_appbuilder package

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@ 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
@@ -304,6 +305,30 @@ 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],
@@ -321,8 +346,10 @@ 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
masked.append(f"$.{match.full_path}")
# 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)}")
return masked

View File

@@ -208,7 +208,6 @@ 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(

View File

@@ -17,10 +17,11 @@
import pytest
from pytest_mock import MockerFixture
from superset.commands.chart.exceptions import ChartForbiddenError
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
from superset.commands.chart.update import UpdateChartCommand
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.utils import json
def _ownership_exc() -> SupersetSecurityException:
@@ -91,3 +92,73 @@ def test_update_chart_owner_can_perform_regular_update(
find_by_id.assert_called_once_with(1)
raise_for_ownership.assert_called_once()
def _query_context_payload(datasource: object) -> dict[str, object]:
return {
"query_context": json.dumps({"datasource": datasource, "queries": []}),
"query_context_generation": True,
}
def test_update_chart_query_context_matching_datasource_is_allowed(
mocker: MockerFixture,
) -> None:
"""A query context that targets the chart's own datasource is accepted."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
UpdateChartCommand(
1, _query_context_payload({"id": 42, "type": "table"})
).validate()
@pytest.mark.parametrize(
"datasource",
[
{"id": 99, "type": "table"}, # different id
{"id": 42, "type": "query"}, # different type
{"id": "99", "type": "table"}, # different id as string
],
)
def test_update_chart_query_context_mismatched_datasource_is_rejected(
mocker: MockerFixture,
datasource: dict[str, object],
) -> None:
"""A query context pointing at a different datasource is rejected with a 4xx."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
with pytest.raises(ChartInvalidError):
UpdateChartCommand(1, _query_context_payload(datasource)).validate()
@pytest.mark.parametrize(
"query_context",
[
"{}", # no datasource key
'{"datasource": null}', # null datasource
"not-json", # unparseable payload
],
)
def test_update_chart_query_context_without_datasource_is_allowed(
mocker: MockerFixture,
query_context: str,
) -> None:
"""Payloads with no verifiable datasource fall back to the chart's own."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
UpdateChartCommand(
1,
{"query_context": query_context, "query_context_generation": True},
).validate()

View File

@@ -312,119 +312,3 @@ 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)

View File

@@ -0,0 +1,81 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
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",
]