mirror of
https://github.com/apache/superset.git
synced 2026-06-11 02:29:19 +00:00
Compare commits
25 Commits
fix/helm-e
...
fix/chart-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d544bff071 | ||
|
|
f79a88c685 | ||
|
|
b1d965932d | ||
|
|
7d046340dc | ||
|
|
aa872cd0a1 | ||
|
|
b2c5a1ecb3 | ||
|
|
6cd9bdee0b | ||
|
|
a8a1d9c17d | ||
|
|
97058d2cf0 | ||
|
|
ef57409209 | ||
|
|
5f06e66cf1 | ||
|
|
11af932099 | ||
|
|
c9c05d8d0a | ||
|
|
0f59705806 | ||
|
|
320965612d | ||
|
|
c3df60c12b | ||
|
|
4f69949c10 | ||
|
|
3380496e9f | ||
|
|
248ccadecd | ||
|
|
cc5a3ddd05 | ||
|
|
f27424d72e | ||
|
|
5a0e3f15ca | ||
|
|
3d1253c992 | ||
|
|
2b58411391 | ||
|
|
08b8bdecbd |
28
.github/dependabot.yml
vendored
28
.github/dependabot.yml
vendored
@@ -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
|
||||
|
||||
12
UPDATING.md
12
UPDATING.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
8813
superset-frontend/package-lock.json
generated
8813
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 '.';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 '.';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 '.';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 '.';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 '.';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 '.';
|
||||
|
||||
@@ -35,7 +35,7 @@ test('format milliseconds in human readable format with default options', () =>
|
||||
});
|
||||
test('format seconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter({ multiplier: 1000 });
|
||||
expect(formatter(-0.5)).toBe('-0s');
|
||||
expect(formatter(-0.5)).toBe('0s');
|
||||
expect(formatter(0.5)).toBe('0s');
|
||||
expect(formatter(1)).toBe('1s');
|
||||
expect(formatter(30)).toBe('30s');
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"dompurify": "^3.4.8",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "~9.3.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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,10 +42,10 @@ const options: {
|
||||
export const ControlHeaderGallery = () => (
|
||||
<>
|
||||
{Object.entries(options).map(([name, props]) => (
|
||||
<>
|
||||
<div key={name}>
|
||||
<h4>{name}</h4>
|
||||
<ControlHeader {...props} />
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
2
superset-websocket/package-lock.json
generated
2
superset-websocket/package-lock.json
generated
@@ -25,7 +25,7 @@
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
118
superset-websocket/utils/client-ws-app/package-lock.json
generated
118
superset-websocket/utils/client-ws-app/package-lock.json
generated
@@ -13,7 +13,7 @@
|
||||
"express": "~5.2.1",
|
||||
"http-errors": "~2.0.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"morgan": "~1.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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
54
superset/cli/guest_token.py
Normal file
54
superset/cli/guest_token.py
Normal 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",
|
||||
)
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.,
|
||||
#
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
16267
superset/translations/ro/LC_MESSAGES/messages.po
Normal file
16267
superset/translations/ro/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
16
tests/unit_tests/cli/__init__.py
Normal file
16
tests/unit_tests/cli/__init__.py
Normal 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.
|
||||
58
tests/unit_tests/cli/guest_token_test.py
Normal file
58
tests/unit_tests/cli/guest_token_test.py
Normal 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
|
||||
@@ -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
Reference in New Issue
Block a user