Compare commits

..

25 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
Joe Li
f27424d72e fix(embedded): show filter bar controls on embedded dashboards with tabs (#39417)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-10 09:53:10 -07:00
Evan Rusackas
5a0e3f15ca feat(embedded): add guest token revocation support (#40671)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-10 09:17:30 -07:00
Đỗ Trọng Hải
3d1253c992 build(major,dev-deps): upgrade Storybook from v8 to v10 (#40713)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-06-10 23:05:18 +07:00
dependabot[bot]
2b58411391 chore(deps): bump ag-grid-react from 35.3.0 to 35.3.1 in /superset-frontend (#40928)
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 22:53:40 +07:00
Evan Rusackas
08b8bdecbd fix(charts): tighten chart schema input validation (query_context JSON, prophet/rolling bounds) (#40634)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-10 08:17:12 -07:00
103 changed files with 20655 additions and 7176 deletions

View File

@@ -14,12 +14,6 @@ updates:
- package-ecosystem: "npm"
ignore:
# TODO: remove below entries until React >= 18.0.0
- dependency-name: "storybook"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "@storybook*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eslint-plugin-storybook"
- dependency-name: "react-error-boundary"
- dependency-name: "@rjsf/*"
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
@@ -42,14 +36,6 @@ updates:
# and confirm the issue https://github.com/apache/superset/issues/39600 is fixed
- dependency-name: "react-checkbox-tree"
update-types: ["version-update:semver-major"]
groups:
storybook:
applies-to: version-updates
patterns:
- "@storybook*"
- "storybook"
update-types:
- "patch"
directory: "/superset-frontend/"
schedule:
interval: "daily"
@@ -90,21 +76,7 @@ updates:
- package-ecosystem: "npm"
directory: "/docs/"
ignore:
# TODO: remove below entries until React >= 18.0.0 in superset-frontend
- dependency-name: "storybook"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "@storybook*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eslint-plugin-storybook"
- dependency-name: "react-error-boundary"
groups:
storybook:
applies-to: version-updates
patterns:
- "@storybook*"
- "storybook"
update-types:
- "patch"
schedule:
interval: "daily"
open-pull-requests-limit: 10

View File

@@ -58,6 +58,18 @@ GLOBAL_ASYNC_QUERIES_JWT_SECRET = "<output of: openssl rand -base64 42>"
The check is only active when the relevant feature is enabled, so deployments that do not use global async queries (or embedding) are not affected.
### Guest token revocation (opt-in)
Embedded guest tokens can be coarsely revoked at runtime via a new opt-in mechanism. A new config flag `GUEST_TOKEN_REVOCATION_ENABLED` (default `False`) gates the feature. When enabled, every minted guest token carries a revocation version, and tokens whose version is below the current expected version (stored in the metadata database) are rejected at validation time.
Bump the expected version with the new CLI command to invalidate all outstanding guest tokens:
```bash
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.
### 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

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.16.1 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.16.1](https://img.shields.io/badge/Version-0.16.1-informational?style=flat-square)
![Version: 0.16.0](https://img.shields.io/badge/Version-0.16.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -126,7 +126,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
{{- end }}
{{- if .Values.supersetCeleryBeat.extraContainers }}
{{- tpl (toYaml .Values.supersetCeleryBeat.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetCeleryBeat.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -121,7 +121,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
{{- end }}
{{- if .Values.supersetCeleryFlower.extraContainers }}
{{- tpl (toYaml .Values.supersetCeleryFlower.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetCeleryFlower.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -141,7 +141,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
{{- end }}
{{- if .Values.supersetWorker.extraContainers }}
{{- tpl (toYaml .Values.supersetWorker.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetWorker.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -120,7 +120,7 @@ spec:
livenessProbe: {{- .Values.supersetWebsockets.livenessProbe | toYaml | nindent 12 }}
{{- end }}
{{- if .Values.supersetWebsockets.extraContainers }}
{{- tpl (toYaml .Values.supersetWebsockets.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetWebsockets.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -151,7 +151,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
{{- end }}
{{- if .Values.supersetNode.extraContainers }}
{{- tpl (toYaml .Values.supersetNode.extraContainers) . | nindent 8 }}
{{- toYaml .Values.supersetNode.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

View File

@@ -101,7 +101,7 @@ spec:
command: {{ tpl (toJson .Values.init.command) . }}
resources: {{- toYaml .Values.init.resources | nindent 10 }}
{{- if .Values.init.extraContainers }}
{{- tpl (toYaml .Values.init.extraContainers) . | nindent 6 }}
{{- toYaml .Values.init.extraContainers | nindent 6 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}

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

@@ -1,4 +1,3 @@
import { dirname, join } from 'path';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -17,8 +16,16 @@ import { dirname, join } from 'path';
* specific language governing permissions and limitations
* under the License.
*/
// This file has been automatically migrated to valid ESM format by Storybook.
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
// Superset's webpack.config.js
const customConfig = require('../webpack.config.js');
import customConfig from '../webpack.config.js';
const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Filter out plugins that shouldn't be included in Storybook's static build
// ReactRefreshWebpackPlugin adds Fast Refresh code that requires a dev server runtime,
@@ -76,7 +83,7 @@ const disableDevModeInRules = rules =>
};
});
module.exports = {
export default {
stories: [
'../src/**/*.stories.tsx',
'../packages/superset-ui-core/src/**/*.stories.tsx',
@@ -84,11 +91,8 @@ module.exports = {
],
addons: [
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-links'),
'@mihkeleidast/storybook-addon-source',
getAbsolutePath('@storybook/addon-controls'),
getAbsolutePath('@storybook/addon-mdx-gfm'),
"@storybook/addon-links",
"@storybook/addon-docs"
],
staticDirs: ['../src/assets/images'],
@@ -105,11 +109,13 @@ module.exports = {
alias: {
...config.resolve?.alias,
...customConfig.resolve?.alias,
// Fix for Storybook 8.6.x with React 17 - resolve ESM module paths
'react-dom/test-utils': require.resolve('react-dom/test-utils'),
// Shared storybook utilities
'@storybook-shared': join(__dirname, 'shared'),
'@storybook-shared': path.join(__dirname, 'shared'),
},
fallback: {
tty: false,
vm: require.resolve('vm-browserify')
}
},
plugins: [...config.plugins, ...filteredPlugins],
}),
@@ -119,15 +125,11 @@ module.exports = {
},
framework: {
name: getAbsolutePath('@storybook/react-webpack5'),
name: getAbsolutePath("@storybook/react-webpack5"),
options: {},
},
docs: {
autodocs: false,
},
}
};
function getAbsolutePath(value) {
return dirname(require.resolve(join(value, 'package.json')));
return path.dirname(require.resolve(path.join(value, 'package.json')));
}

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { withJsx } from '@mihkeleidast/storybook-addon-source';
import { themeObject, css, exampleThemes } from '@apache-superset/core/theme';
import { combineReducers, createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
@@ -114,9 +113,12 @@ const providerDecorator = Story => (
</Provider>
);
export const decorators = [withJsx, themeDecorator, providerDecorator];
export const decorators = [themeDecorator, providerDecorator];
export const parameters = {
docs: {
codePanel: true,
},
paddings: {
values: [
{ name: 'None', value: '0px' },

View File

@@ -19,7 +19,7 @@
import { useState, ReactNode, SyntheticEvent } from 'react';
import { styled } from '@apache-superset/core/theme';
import type { Decorator } from '@storybook/react';
import type { Decorator } from '@storybook/react-webpack5';
import { ResizeCallbackData } from 'react-resizable';
import ResizablePanel, { Size } from './ResizablePanel';

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

@@ -69,7 +69,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.)',
],
preset: 'ts-jest',
transform: {

File diff suppressed because it is too large Load Diff

View File

@@ -164,8 +164,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "35.3.0",
"ag-grid-react": "35.3.0",
"ag-grid-community": "35.3.1",
"ag-grid-react": "35.3.1",
"antd": "^5.26.0",
"chrono-node": "^2.9.1",
"classnames": "^2.2.5",
@@ -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,22 +261,14 @@
"@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",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.60.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-actions": "^8.6.18",
"@storybook/addon-controls": "^8.6.18",
"@storybook/addon-essentials": "^8.6.18",
"@storybook/addon-links": "^8.6.18",
"@storybook/addon-mdx-gfm": "^8.6.18",
"@storybook/components": "^8.6.18",
"@storybook/preview-api": "^8.6.18",
"@storybook/react": "^8.6.18",
"@storybook/react-webpack5": "^8.6.18",
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.17.0",
"@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",
"@swc/plugin-emotion": "^14.12.0",
@@ -297,7 +289,6 @@
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-resizable": "^4.0.0",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1.8.8",
@@ -334,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": "^0.8.0",
"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",
@@ -364,7 +355,7 @@
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "8.6.18",
"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

@@ -31,8 +31,8 @@
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.44.0",
"ag-grid-community": "35.3.0",
"ag-grid-react": "35.3.0",
"ag-grid-community": "35.3.1",
"ag-grid-react": "35.3.1",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"core-js": "^3.49.0",
@@ -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

@@ -17,7 +17,7 @@
* under the License.
*/
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { AutoComplete } from '.';
import type { AutoCompleteProps } from './types';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { Breadcrumb } from '.';
import type { BreadcrumbProps } from './types';

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from '@storybook/addon-actions';
import { Meta, StoryFn } from '@storybook/react';
import { action } from 'storybook/actions';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { CachedLabel } from '.';
import type { CacheLabelProps } from './types';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useArgs } from '@storybook/preview-api';
import { useArgs } from 'storybook/preview-api';
import { useState } from 'react';
import { Checkbox } from '.';
import type { CheckboxProps, CheckboxChangeEvent } from './types';

View File

@@ -17,7 +17,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryFn } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { Row, Col } from '@superset-ui/core/components';
import { EmptyState, imageMap } from '.';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { FaveStar } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react-webpack5';
import Slider from '@superset-ui/core/components/Slider/index';
import { useState } from 'react';
import { Row, Col } from '.';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { IconButton } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { StoryObj } from '@storybook/react';
import type { StoryObj } from '@storybook/react-webpack5';
import { Input, InputNumber } from '.';
import type { InputProps, InputNumberProps, TextAreaProps } from './types';

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from '@storybook/addon-actions';
import { Meta, StoryFn } from '@storybook/react';
import { action } from 'storybook/actions';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import type { LabelType } from './types';
import { Label, DatasetTypeLabel, PublishedLabel } from '.';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { Icons } from '@superset-ui/core/components/Icons';
import { Menu } from '../Menu';
import type { LayoutProps, SiderProps } from './types';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { ListViewCard } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { StoryObj } from '@storybook/react';
import type { StoryObj } from '@storybook/react-webpack5';
import { css } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { Space } from '../Space';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryFn } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { SafeMarkdown } from './SafeMarkdown';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { Space } from '../Space';
import { Skeleton, type SkeletonProps } from '.';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useArgs } from '@storybook/preview-api';
import { useArgs } from 'storybook/preview-api';
import { Switch, type SwitchProps } from '.';
export default {

View File

@@ -18,8 +18,8 @@
*/
import { useState, DragEvent } from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { Meta, StoryFn } from '@storybook/react-webpack5';
import { action } from 'storybook/actions';
import {
ColumnsType,
ETableAction,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react';
import { StoryFn, Meta } from '@storybook/react-webpack5';
import ActionCell from './index';
import { exampleMenuOptions, exampleRow } from './fixtures';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { ActionMenuItem } from './index';
export const exampleMenuOptions: ActionMenuItem[] = [

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react';
import { StoryFn, Meta } from '@storybook/react-webpack5';
import BooleanCell from '.';
export default {

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { action } from 'storybook/actions';
import { ButtonCell } from './index';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react';
import { StoryFn, Meta } from '@storybook/react-webpack5';
import NullCell from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react';
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { CurrencyCode, NumericCell, LocaleCode, Style } from './index';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react';
import { StoryFn, Meta } from '@storybook/react-webpack5';
import { TimeFormats } from '@superset-ui/core';
import TimeCell from '.';

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useMemo, useState, useCallback } from 'react';
import { Meta, StoryFn } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import {
useTable,
useSortBy,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useArgs } from '@storybook/preview-api';
import { useArgs } from 'storybook/preview-api';
import TimezoneSelector, { TimezoneSelectorProps } from './index';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { StoryObj } from '@storybook/react';
import { StoryObj } from '@storybook/react-webpack5';
import { Icons } from '@superset-ui/core/components/Icons';
import Tree, { TreeProps, type TreeDataNode } from './index';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { TreeSelect, type TreeSelectProps } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { Typography } from '.';
export default {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { StoryObj } from '@storybook/react';
import type { StoryObj } from '@storybook/react-webpack5';
import { Icons } from '../Icons';
import { Button } from '../Button';
import { Upload } from '.';

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

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryFn } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react-webpack5';
import { Card, Col, Layout, Row } from '@superset-ui/core/components';
import { ErrorAlert } from './ErrorAlert';

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

@@ -44,14 +44,14 @@ const options: { [key in string]: RowCountLabelProps } = {
export const RowCountLabelGallery = () => (
<>
{Object.keys(options).map(name => (
<>
<div key={name}>
<h4>{name}</h4>
<RowCountLabel
loading={options[name].loading}
rowcount={options[name].rowcount}
limit={options[name].limit}
/>
</>
</div>
))}
</>
);

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { Tag } from 'src/components/Tag';
import type { CheckableTagProps } from 'src/components/Tag';
import type { TagType } from 'src/types/TagType';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Meta, StoryObj } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react-webpack5';
import { TagsList, type TagsListProps } from 'src/components/TagsList';
export default {

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

@@ -41,11 +41,21 @@ jest.mock('react-redux', () => {
});
beforeEach(() => {
(useSelector as jest.Mock).mockImplementation(selector => {
if (selector.name === 'useActiveDashboardTabs') {
return ['TAB_1'];
}
return [];
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['TAB_1'] },
dashboardLayout: {
present: {
'CHART-123': {
type: 'CHART',
meta: { chartId: 123 },
parents: ['ROOT_ID', 'TAB_1'],
},
TAB_1: { type: 'TAB', id: 'TAB_1' },
},
},
};
return selector(mockState);
});
});
@@ -99,6 +109,14 @@ test('useIsFilterInScope should return false for filters with inactive rootPath'
});
test('useSelectFiltersInScope should return all filters in scope when no tabs exist', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: {} },
};
return selector(mockState);
});
const filters: Filter[] = [
{
id: 'filter_1',
@@ -601,3 +619,479 @@ test('useChartCustomizationConfiguration ignores undefined items in metadata', (
expect.objectContaining({ id: 'CHART_CUSTOMIZATION-1' }),
);
});
// --- Embedded / hideTab: activeTabs is empty ---
// When an embedded dashboard uses hideTab:true, the Tabs component never
// mounts, so setActiveTab never fires and activeTabs stays []. The same
// empty state occurs transiently on first render of any tabbed dashboard.
//
// useActiveDashboardTabs derives the default first tab from the layout when
// Redux activeTabs is empty, so scope evaluation uses the correct default
// tab instead of either "no tabs active" (blank filter bar) or "all tabs"
// (showing out-of-scope filters).
// Helper: build a layout with ROOT_ID → TABS container → TAB children
function embeddedLayout(extras: Record<string, Record<string, unknown>> = {}) {
return {
ROOT_ID: {
type: 'ROOT',
id: 'ROOT_ID',
children: ['TABS-1'],
},
'TABS-1': {
type: 'TABS',
id: 'TABS-1',
children: ['TAB-Company', 'TAB-Desktop'],
},
'TAB-Company': {
type: 'TAB',
id: 'TAB-Company',
children: ['CHART-1', 'CHART-2'],
},
'TAB-Desktop': {
type: 'TAB',
id: 'TAB-Desktop',
children: ['CHART-3'],
},
'CHART-1': {
type: 'CHART',
meta: { chartId: 1 },
parents: ['ROOT_ID', 'TAB-Company'],
},
'CHART-2': {
type: 'CHART',
meta: { chartId: 2 },
parents: ['ROOT_ID', 'TAB-Company'],
},
'CHART-3': {
type: 'CHART',
meta: { chartId: 3 },
parents: ['ROOT_ID', 'TAB-Desktop'],
},
...extras,
};
}
test('useIsFilterInScope: filter scoped to default tab is in-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_default_tab',
name: 'Default Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1, 2],
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter scoped to default (first) tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_other_tab',
name: 'Other Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [3],
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter scoped only to non-default tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(false);
});
test('useIsFilterInScope: filter with rootPath to default tab is in-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_rootpath_default',
name: 'RootPath Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter using rootPath to default tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('useSelectFiltersInScope: only default-tab filters are in scope when activeTabs is empty (embedded hideTab)', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filters: Filter[] = [
{
id: 'filter_company',
name: 'Company Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1, 2],
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'survey_rating' }, datasetId: 1 }],
description: 'Filter scoped to default tab',
},
{
id: 'filter_desktop_only',
name: 'Desktop Only Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [3],
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'pool_name' }, datasetId: 2 }],
description: 'Filter scoped only to non-default tab',
},
];
const { result } = renderHook(() => useSelectFiltersInScope(filters));
expect(result.current[0]).toHaveLength(1);
expect(result.current[0][0]).toEqual(
expect.objectContaining({ id: 'filter_company' }),
);
expect(result.current[1]).toHaveLength(1);
expect(result.current[1][0]).toEqual(
expect.objectContaining({ id: 'filter_desktop_only' }),
);
});
test('useSelectFiltersInScope: dividers are always in scope even when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const items: (Filter | Divider)[] = [
{
id: 'divider_embedded',
type: NativeFilterType.Divider,
title: 'Section',
description: 'Divider in embedded mode',
},
{
id: 'filter_default',
name: 'Default Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1],
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter in default tab',
},
];
const { result } = renderHook(() => useSelectFiltersInScope(items));
expect(result.current[0]).toHaveLength(2);
expect(result.current[1]).toHaveLength(0);
});
test('useSelectFiltersInScope: correctly scopes chartsInScope filters when activeTabs is populated', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: ['TAB-Company'] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filters: Filter[] = [
{
id: 'filter_active_tab',
name: 'Active Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1],
scope: { rootPath: ['TAB-Company'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter scoped to active tab',
},
{
id: 'filter_inactive_tab',
name: 'Inactive Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [3],
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 2 }],
description: 'Filter scoped to inactive tab',
},
];
const { result } = renderHook(() => useSelectFiltersInScope(filters));
expect(result.current[0]).toHaveLength(1);
expect(result.current[0][0]).toEqual(
expect.objectContaining({ id: 'filter_active_tab' }),
);
expect(result.current[1]).toHaveLength(1);
expect(result.current[1][0]).toEqual(
expect.objectContaining({ id: 'filter_inactive_tab' }),
);
});
// Layout-derived default-tab fallback edge cases. These pin behavior of
// useActiveDashboardTabs when activeTabs is empty across structural variants
// of dashboardLayout, so the fallback can't silently regress.
test('useIsFilterInScope: dashboard with no top-level tabs (root child is GRID_ID) treats filters as in-scope', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: {
present: {
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: { type: 'GRID', id: 'GRID_ID', children: ['CHART-1'] },
'CHART-1': {
type: 'CHART',
meta: { chartId: 1 },
parents: ['ROOT_ID', 'GRID_ID'],
},
},
},
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_no_tabs',
name: 'No-tabs filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter on a no-tabs dashboard',
};
const { result } = renderHook(() => useIsFilterInScope());
// Chart has no TAB ancestors → tabParents is empty → considered in-scope.
expect(result.current(filter)).toBe(true);
});
test('useIsFilterInScope: missing dashboardLayout falls back without crashing', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: undefined },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_no_layout',
name: 'No layout',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [1],
scope: { rootPath: ['TAB-A'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter when layout is missing',
};
// The hook should not throw when layout is missing. Without the
// useActiveDashboardTabs guard, indexing dashboardLayout[ROOT_ID] would
// crash here.
const { result } = renderHook(() => useIsFilterInScope());
expect(() => result.current(filter)).not.toThrow();
});
// Shared fixture for the two nested-tabs tests below. Layout is identical;
// only the redux activeTabs differs (empty for the default-path test,
// inner-only for the hideTab ancestor-merge test).
const nestedTabsLayout = () => ({
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['TABS-1'] },
'TABS-1': {
type: 'TABS',
id: 'TABS-1',
children: ['TAB-Outer1', 'TAB-Outer2'],
},
'TAB-Outer1': {
type: 'TAB',
id: 'TAB-Outer1',
children: ['TABS-2'],
},
'TAB-Outer2': {
type: 'TAB',
id: 'TAB-Outer2',
children: ['CHART-Outer2'],
},
'TABS-2': {
type: 'TABS',
id: 'TABS-2',
children: ['TAB-Inner1', 'TAB-Inner2'],
},
'TAB-Inner1': {
type: 'TAB',
id: 'TAB-Inner1',
children: ['CHART-Inner1'],
},
'TAB-Inner2': {
type: 'TAB',
id: 'TAB-Inner2',
children: ['CHART-Inner2'],
},
'CHART-Inner1': {
type: 'CHART',
meta: { chartId: 11 },
parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner1'],
},
'CHART-Inner2': {
type: 'CHART',
meta: { chartId: 12 },
parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner2'],
},
'CHART-Outer2': {
type: 'CHART',
meta: { chartId: 20 },
parents: ['ROOT_ID', 'TAB-Outer2'],
},
});
const mockNestedTabsState = (activeTabs: string[]) => ({
dashboardState: { activeTabs },
dashboardLayout: { present: nestedTabsLayout() },
});
test('useIsFilterInScope: deeply nested tabs — default path includes inner-tab default', () => {
// ROOT → TABS-1 → [TAB-Outer1, TAB-Outer2]
// └─ TAB-Outer1 → TABS-2 → [TAB-Inner1, TAB-Inner2]
// Default path should be ['TAB-Outer1', 'TAB-Inner1'].
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
selector(mockNestedTabsState([])),
);
const innerDefaultFilter: Filter = {
id: 'filter_inner1',
name: 'Inner default-tab filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [11],
scope: { rootPath: ['TAB-Inner1'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter scoped to inner default tab',
};
const innerNonDefaultFilter: Filter = {
...innerDefaultFilter,
id: 'filter_inner2',
chartsInScope: [12],
scope: { rootPath: ['TAB-Inner2'], excluded: [] },
};
const { result } = renderHook(() => useIsFilterInScope());
// CHART-Inner1's tab parents [TAB-Outer1, TAB-Inner1] are both in default
// path → in scope.
expect(result.current(innerDefaultFilter)).toBe(true);
// CHART-Inner2's tab parent TAB-Inner2 is not in default path → out of scope.
expect(result.current(innerNonDefaultFilter)).toBe(false);
});
test('useIsFilterInScope: nested Tabs mounted under hideTab:true — outer ancestor merged so outer-tab scoping is preserved', () => {
// hideTab:true skips the top-level Tabs but a nested Tabs can still mount
// and dispatch setActiveTab. activeTabs holds only the inner id; without
// ancestor merging, filters whose charts have tabParents=[outer, inner]
// would be marked out-of-scope because the outer id is missing.
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
selector(mockNestedTabsState(['TAB-Inner1'])),
);
const innerActiveFilter: Filter = {
id: 'filter_inner1_active',
name: 'Filter scoped to active inner tab',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [11],
scope: { rootPath: ['TAB-Inner1'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 1 }],
description: 'Filter on the active inner tab',
};
const otherOuterFilter: Filter = {
id: 'filter_other_outer',
name: 'Filter scoped to non-default outer tab',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [20],
scope: { rootPath: ['TAB-Outer2'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'col' }, datasetId: 2 }],
description: 'Filter on the other outer tab — must stay out of scope',
};
const { result } = renderHook(() => useIsFilterInScope());
// Outer ancestor TAB-Outer1 is merged into the active path → in scope.
expect(result.current(innerActiveFilter)).toBe(true);
// TAB-Outer2 is not in the active path → out of scope, scoping preserved.
expect(result.current(otherOuterFilter)).toBe(false);
});

View File

@@ -30,7 +30,8 @@ import {
} from '@superset-ui/core';
import { FilterElement } from './FilterBar/FilterControls/types';
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { CHART_TYPE, TAB_TYPE, TABS_TYPE } from '../../util/componentTypes';
import { DASHBOARD_ROOT_ID } from '../../util/constants';
import { isChartCustomizationId } from './FiltersConfigModal/utils';
import {
migrateChartCustomizationArray,
@@ -38,6 +39,7 @@ import {
} from '../../util/migrateChartCustomization';
const EMPTY_ARRAY: ChartCustomizationConfiguration = [];
const EMPTY_ACTIVE_TABS: ActiveTabs = [];
const defaultFilterConfiguration: (Filter | Divider)[] = [];
export const selectFilterConfiguration: (
@@ -175,22 +177,83 @@ export function useDashboardHasTabs() {
const dashboardLayout = useDashboardLayout();
return useMemo(
() =>
Object.values(dashboardLayout).some(element => element.type === TAB_TYPE),
dashboardLayout
? Object.values(dashboardLayout).some(
element => element.type === TAB_TYPE,
)
: false,
[dashboardLayout],
);
}
function useActiveDashboardTabs() {
return useSelector<RootState, ActiveTabs>(
function useActiveDashboardTabs(): ActiveTabs {
const reduxTabs = useSelector<RootState, ActiveTabs>(
state => state.dashboardState?.activeTabs,
);
const dashboardLayout = useDashboardLayout();
return useMemo(() => {
const reduxList = reduxTabs ?? [];
const reduxFallback = reduxList.length ? reduxList : EMPTY_ACTIVE_TABS;
if (!dashboardLayout) return reduxFallback;
// Tabbed dashboards always nest the top-level TABS container as the first
// child of ROOT. If that invariant doesn't hold (no-tabs layout), no
// fallback applies and we use reduxTabs as-is.
const root = dashboardLayout[DASHBOARD_ROOT_ID];
if (!root?.children?.length) return reduxFallback;
const topContainer = dashboardLayout[root.children[0]];
if (topContainer?.type !== TABS_TYPE || !topContainer.children?.length) {
return reduxFallback;
}
// Walk every TABS container along the active path. For each container,
// pick the child Redux marked active; otherwise pick the first child (the
// default the live Tabs component would render). This handles:
// - empty reduxTabs (hideTab:true, no permalink) → full default path
// - reduxTabs missing an outer ancestor (hideTab:true skipped the
// top-level Tabs, but a nested Tabs dispatched setActiveTab) → fill
// in the missing ancestor so outer-tab scoping is preserved
// - fully populated reduxTabs (normal hydration) → same result
const reduxSet = new Set(reduxList);
const result: ActiveTabs = [];
const queue: string[] = [
topContainer.children.find(c => reduxSet.has(c)) ??
topContainer.children[0],
];
while (queue.length > 0) {
const tabId = queue.shift()!;
result.push(tabId);
const tab = dashboardLayout[tabId];
if (!tab?.children) continue;
for (const childId of tab.children) {
const child = dashboardLayout[childId];
if (child?.type !== TABS_TYPE || !child.children?.length) continue;
queue.push(
child.children.find(c => reduxSet.has(c)) ?? child.children[0],
);
}
}
// Preserve any reduxTabs entries that fell outside the traversed path so
// we never silently drop a redux-marked active tab id.
const resultSet = new Set(result);
for (const id of reduxList) {
if (!resultSet.has(id)) result.push(id);
}
return result;
}, [reduxTabs, dashboardLayout]);
}
function useSelectChartTabParents() {
const dashboardLayout = useDashboardLayout();
const layoutChartItems = useMemo(
() =>
Object.values(dashboardLayout).filter(item => item.type === CHART_TYPE),
dashboardLayout
? Object.values(dashboardLayout).filter(
item => item.type === CHART_TYPE,
)
: [],
[dashboardLayout],
);
return useCallback(
@@ -199,7 +262,7 @@ function useSelectChartTabParents() {
layoutItem => layoutItem.meta?.chartId === chartId,
);
return chartLayoutItem?.parents?.filter(
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
(parent: string) => dashboardLayout?.[parent]?.type === TAB_TYPE,
);
},
[dashboardLayout, layoutChartItems],
@@ -332,6 +395,7 @@ export function useSelectCustomizationsInScope(
| ChartCustomizationDivider
)[] = [];
// we check customization scopes only on dashboards with tabs
if (!dashboardHasTabs) {
customizationsInScope = customizations;
} else {

View File

@@ -42,10 +42,10 @@ const options: {
export const ControlHeaderGallery = () => (
<>
{Object.entries(options).map(([name, props]) => (
<>
<div key={name}>
<h4>{name}</h4>
<ControlHeader {...props} />
</>
</div>
))}
</>
);

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

@@ -17,7 +17,7 @@
* under the License.
*/
import { StoryFn, Meta } from '@storybook/react';
import { StoryFn, Meta } from '@storybook/react-webpack5';
import DatasetPanel from './DatasetPanel';
import { exampleColumns } from './fixtures';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import RangeFilterPlugin from './index';

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
import { mockQueryDataForCountries } from 'spec/fixtures/mockNativeFilters';
import SelectFilterPlugin from './index';

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

@@ -312,7 +312,9 @@ class ChartPutSchema(Schema):
validate=utils.validate_json,
)
query_context = fields.String(
metadata={"description": query_context_description}, allow_none=True
metadata={"description": query_context_description},
allow_none=True,
validate=utils.validate_json,
)
query_context_generation = fields.Boolean(
metadata={"description": query_context_generation_description}, allow_none=True
@@ -554,8 +556,20 @@ class ChartDataRollingOptionsSchema(ChartDataPostProcessingOperationOptionsSchem
required=True,
)
window = fields.Integer(
metadata={"description": "Size of the rolling window in days.", "example": 7},
metadata={
"description": "Size of the rolling window in days.",
"example": 7,
"min": 1,
"max": 10000,
},
required=True,
validate=[
Range(
min=1,
max=10000,
error=_("`window` must be between 1 and 10000"),
)
],
)
rolling_type_options = fields.Dict(
metadata={
@@ -695,6 +709,7 @@ class ChartDataProphetOptionsSchema(ChartDataPostProcessingOperationOptionsSchem
"the future",
"example": 7,
"min": 1,
"max": DEFAULT_MAX_PROPHET_PERIODS,
},
validate=validate_prophet_periods,
required=True,

View File

@@ -0,0 +1,54 @@
# 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 logging
import click
from flask import current_app
from flask.cli import with_appcontext
logger = logging.getLogger(__name__)
@click.command()
@with_appcontext
def revoke_guest_tokens() -> None:
"""
Revoke all outstanding embedded guest tokens.
Bumps the guest-token revocation version stored in the metadata database,
which invalidates every guest token minted with a lower version. New tokens
minted after this command will carry the bumped version and remain valid.
Requires ``GUEST_TOKEN_REVOCATION_ENABLED = True``; otherwise the bumped
version is not enforced at validation time.
"""
# pylint: disable=import-outside-toplevel
from superset.security.guest_token import bump_guest_token_revocation_version
if not current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"]:
click.secho(
"Warning: GUEST_TOKEN_REVOCATION_ENABLED is False. The version will "
"be bumped but is NOT enforced at validation time until you enable "
"the feature.",
fg="yellow",
)
new_version = bump_guest_token_revocation_version()
click.secho(
f"Revoked outstanding guest tokens. Revocation version is now {new_version}.",
fg="green",
)

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.
@@ -2426,6 +2427,19 @@ GUEST_TOKEN_JWT_AUDIENCE: Callable[[], str] | str | None = None
GUEST_TOKEN_VALIDATOR_HOOK = None
# Enables coarse-grained, runtime revocation of outstanding guest tokens.
#
# When True, every minted guest token carries a revocation version, and tokens
# whose version is below the current expected version (stored in the metadata
# database) are rejected at validation time. Bump the expected version with the
# `superset revoke-guest-tokens` CLI command to invalidate all outstanding guest
# tokens (e.g. after a token leak, or when a user's access or RLS rules change).
#
# This is opt-in and backward compatible: the default expected version is 0 and
# tokens minted before this feature (which carry no version claim) are treated as
# version 0, so nothing is revoked until an admin explicitly bumps the version.
GUEST_TOKEN_REVOCATION_ENABLED = False
# A SQL dataset health check. Note if enabled it is strongly advised that the callable
# be memoized to aid with performance, i.e.,
#

View File

@@ -86,6 +86,19 @@ def set_shared_value(key: SharedKey, value: Any) -> None:
KeyValueDAO.create_entry(RESOURCE, value, CODEC, uuid_key)
@transaction()
def upsert_shared_value(key: SharedKey, value: Any) -> None:
"""
Create or update a shared value by key, using the current hash algorithm.
Unlike :func:`set_shared_value`, this is idempotent and may be called
repeatedly to overwrite an existing entry.
"""
namespace = get_uuid_namespace("")
uuid_key = uuid3(namespace, key)
KeyValueDAO.upsert_entry(RESOURCE, value, CODEC, uuid_key)
def get_permalink_salt(key: SharedKey) -> str:
salt = get_shared_value(key)
if salt is None:

View File

@@ -53,6 +53,9 @@ class SharedKey(StrEnum):
DASHBOARD_PERMALINK_SALT = "dashboard_permalink_salt"
EXPLORE_PERMALINK_SALT = "explore_permalink_salt"
SQLLAB_PERMALINK_SALT = "sqllab_permalink_salt"
# Monotonically increasing version used to revoke outstanding guest tokens.
# Bumping it invalidates every guest token minted with a lower version.
GUEST_TOKEN_REVOCATION_VERSION = "guest_token_revocation_version" # noqa: S105
class KeyValueCodec(ABC):

View File

@@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Optional, TypedDict, Union
from flask_appbuilder.security.sqla.models import Group, Role
@@ -21,6 +22,62 @@ from flask_login import AnonymousUserMixin
from superset.utils.backports import StrEnum
logger = logging.getLogger(__name__)
# JWT claim that carries the revocation version a token was minted with.
GUEST_TOKEN_REVOCATION_CLAIM = "rev" # noqa: S105
# Tokens minted before the revocation feature existed carry no version claim.
# They are treated as version 0, which is only rejected once an admin has
# explicitly bumped the expected version above 0.
DEFAULT_GUEST_TOKEN_REVOCATION_VERSION = 0
def get_current_guest_token_revocation_version() -> int:
"""
Return the minimum guest-token revocation version accepted at validation time.
The value is stored in the metadata database via the ``key_value`` store so it
can be bumped at runtime (e.g. via the ``revoke-guest-tokens`` CLI command)
without a code deploy. A missing entry means revocation has never been
triggered, so the default version is returned.
"""
# pylint: disable=import-outside-toplevel
from superset.key_value.shared_entries import get_shared_value
from superset.key_value.types import SharedKey
try:
value = get_shared_value(SharedKey.GUEST_TOKEN_REVOCATION_VERSION)
except Exception: # pylint: disable=broad-except
# Never let a metadata-store hiccup hard-fail token validation; fall back
# to the default version (fail-open to the pre-feature behaviour).
logger.warning("Could not read guest token revocation version", exc_info=True)
return DEFAULT_GUEST_TOKEN_REVOCATION_VERSION
if value is None:
return DEFAULT_GUEST_TOKEN_REVOCATION_VERSION
try:
return int(value)
except (TypeError, ValueError):
logger.warning("Invalid guest token revocation version stored: %r", value)
return DEFAULT_GUEST_TOKEN_REVOCATION_VERSION
def bump_guest_token_revocation_version() -> int:
"""
Increment and persist the guest-token revocation version, revoking every
outstanding guest token minted with a lower version.
:return: the new revocation version
"""
# pylint: disable=import-outside-toplevel
from superset.key_value.shared_entries import upsert_shared_value
from superset.key_value.types import SharedKey
new_version = get_current_guest_token_revocation_version() + 1
upsert_shared_value(SharedKey.GUEST_TOKEN_REVOCATION_VERSION, new_version)
logger.info("Bumped guest token revocation version to %d", new_version)
return new_version
class GuestTokenUser(TypedDict, total=False):
username: str
@@ -64,6 +121,9 @@ class GuestToken(_GuestTokenRequired, total=False):
"""
datasets: list[int]
# Revocation version the token was minted with. Absent on tokens minted
# before the revocation feature was introduced.
rev: int
class GuestUser(AnonymousUserMixin):

View File

@@ -66,6 +66,9 @@ from superset.exceptions import (
SupersetSecurityException,
)
from superset.security.guest_token import (
DEFAULT_GUEST_TOKEN_REVOCATION_VERSION,
get_current_guest_token_revocation_version,
GUEST_TOKEN_REVOCATION_CLAIM,
GuestToken,
GuestTokenResources,
GuestTokenResourceType,
@@ -3638,6 +3641,10 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"user": user,
"resources": resources,
"rls_rules": rls,
# revocation version: bumping the expected version (see
# GUEST_TOKEN_REVOCATION_ENABLED) invalidates tokens minted with a
# lower version.
GUEST_TOKEN_REVOCATION_CLAIM: self._get_guest_token_revocation_version(),
# standard jwt claims:
"iat": now, # issued at
"exp": exp, # expiration time
@@ -3648,6 +3655,18 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
claims["datasets"] = datasets
return self.pyjwt_for_guest_token.encode(claims, secret, algorithm=algo)
@staticmethod
def _get_guest_token_revocation_version() -> int:
"""
Return the revocation version to stamp into newly minted guest tokens.
Reading the version is gated on ``GUEST_TOKEN_REVOCATION_ENABLED`` so that
deployments which have not opted in never touch the metadata store.
"""
if not get_conf()["GUEST_TOKEN_REVOCATION_ENABLED"]:
return DEFAULT_GUEST_TOKEN_REVOCATION_VERSION
return get_current_guest_token_revocation_version()
def get_guest_user_from_request(self, req: Request) -> Optional[GuestUser]:
"""
If there is a guest token in the request (used for embedded),
@@ -3673,6 +3692,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
raise ValueError("Guest token does not contain an rls_rules claim")
if token.get("type") != "guest":
raise ValueError("This is not a guest token.")
if self._is_guest_token_revoked(token):
raise ValueError("This guest token has been revoked.")
except Exception: # pylint: disable=broad-except
# The login manager will handle sending 401s.
# We don't need to send a special error message.
@@ -3681,6 +3702,29 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return self.get_guest_user_from_token(cast(GuestToken, token))
@staticmethod
def _is_guest_token_revoked(token: dict[str, Any]) -> bool:
"""
Determine whether a guest token has been revoked via a version bump.
Revocation is opt-in (``GUEST_TOKEN_REVOCATION_ENABLED``). When disabled,
no token is ever considered revoked. When enabled, a token is revoked if
the version it was minted with is below the expected version. Tokens
minted before this feature existed carry no version claim and are treated
as :data:`DEFAULT_GUEST_TOKEN_REVOCATION_VERSION` (0), so they only become
revoked once an admin has explicitly bumped the expected version above 0.
"""
if not get_conf()["GUEST_TOKEN_REVOCATION_ENABLED"]:
return False
token_version = token.get(
GUEST_TOKEN_REVOCATION_CLAIM, DEFAULT_GUEST_TOKEN_REVOCATION_VERSION
)
try:
token_version = int(token_version)
except (TypeError, ValueError):
token_version = DEFAULT_GUEST_TOKEN_REVOCATION_VERSION
return token_version < get_current_guest_token_revocation_version()
def get_guest_user_from_token(self, token: GuestToken) -> GuestUser:
return self.guest_user_cls(
token=token,

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

@@ -22,7 +22,9 @@ from marshmallow import ValidationError
from superset.charts.schemas import (
ChartDataProphetOptionsSchema,
ChartDataQueryObjectSchema,
ChartDataRollingOptionsSchema,
ChartPostSchema,
ChartPutSchema,
DEFAULT_MAX_PROPHET_PERIODS,
get_max_prophet_periods,
get_time_grain_choices,
@@ -94,6 +96,79 @@ def test_chart_data_prophet_options_schema_time_grain_validation(
assert "time_grain" in exc_info.value.messages
def test_chart_put_schema_query_context_json_validation(
app_context: None,
) -> None:
"""ChartPutSchema.query_context must reject invalid JSON (parity with POST)."""
schema = ChartPutSchema()
# Valid JSON passes
assert schema.load({"query_context": '{"a": 1}'})["query_context"] == '{"a": 1}'
# None is allowed (allow_none)
assert schema.load({"query_context": None})["query_context"] is None
# Invalid JSON is rejected
with pytest.raises(ValidationError) as exc_info:
schema.load({"query_context": "{not valid json"})
assert "query_context" in exc_info.value.messages
def test_chart_data_prophet_options_schema_periods_range(
app_context: None,
) -> None:
"""`periods` must be a bounded positive integer."""
schema = ChartDataProphetOptionsSchema()
base = {"time_grain": "P1D", "confidence_interval": 0.8}
# Valid value passes
assert schema.load({**base, "periods": 7})["periods"] == 7
# Inclusive boundaries are accepted
assert schema.load({**base, "periods": 1})["periods"] == 1
assert schema.load({**base, "periods": 10000})["periods"] == 10000
# Zero rejected (at least one period must be forecast)
with pytest.raises(ValidationError) as exc_info:
schema.load({**base, "periods": 0})
assert "periods" in exc_info.value.messages
# Negative value rejected
with pytest.raises(ValidationError) as exc_info:
schema.load({**base, "periods": -1})
assert "periods" in exc_info.value.messages
# Excessively large value rejected (resource-exhaustion guard)
with pytest.raises(ValidationError) as exc_info:
schema.load({**base, "periods": 1_000_000})
assert "periods" in exc_info.value.messages
def test_chart_data_rolling_options_schema_window_range(
app_context: None,
) -> None:
"""`window` must be a bounded positive integer."""
schema = ChartDataRollingOptionsSchema()
base = {"rolling_type": "mean"}
# Valid value passes
assert schema.load({**base, "window": 7})["window"] == 7
# Inclusive boundaries are accepted
assert schema.load({**base, "window": 1})["window"] == 1
assert schema.load({**base, "window": 10000})["window"] == 10000
# Zero window rejected (rolling requires window > 0)
with pytest.raises(ValidationError) as exc_info:
schema.load({**base, "window": 0})
assert "window" in exc_info.value.messages
# Excessively large window rejected
with pytest.raises(ValidationError) as exc_info:
schema.load({**base, "window": 1_000_000})
assert "window" in exc_info.value.messages
def test_chart_data_query_object_schema_time_grain_sqla_validation(
app_context: None,
) -> None:

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,58 @@
# 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 click.testing import CliRunner
from flask import current_app
from pytest_mock import MockerFixture
from superset.cli.guest_token import revoke_guest_tokens
def test_revoke_guest_tokens_reports_new_version(
mocker: MockerFixture, app_context: None
) -> None:
"""The command bumps the version and reports it on success (exit 0)."""
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = True
bump = mocker.patch(
"superset.security.guest_token.bump_guest_token_revocation_version",
return_value=7,
)
result = CliRunner().invoke(revoke_guest_tokens)
assert result.exit_code == 0
bump.assert_called_once_with()
assert "Revoked outstanding guest tokens" in result.output
assert "7" in result.output
def test_revoke_guest_tokens_warns_when_feature_disabled(
mocker: MockerFixture, app_context: None
) -> None:
"""With the feature off, the command still bumps but emits a warning."""
current_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = False
bump = mocker.patch(
"superset.security.guest_token.bump_guest_token_revocation_version",
return_value=1,
)
result = CliRunner().invoke(revoke_guest_tokens)
assert result.exit_code == 0
bump.assert_called_once_with()
assert "GUEST_TOKEN_REVOCATION_ENABLED is False" in result.output
# The version is still bumped and reported even when not enforced.
assert "Revocation version is now 1" in result.output

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()

Some files were not shown because too many files have changed in this diff Show More