mirror of
https://github.com/apache/superset.git
synced 2026-08-17 21:51:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b29ae4320 |
@@ -21,9 +21,6 @@ on:
|
||||
#schedule:
|
||||
# - cron: '0 0 * * *' # Runs daily at midnight UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
bump-python-package:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -16,9 +16,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check_db_migration_conflict:
|
||||
name: Check DB migration conflict
|
||||
|
||||
@@ -14,9 +14,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -33,9 +33,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
enforce-single-migration-head:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -7,9 +7,6 @@ on:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
superbot-orglabel:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -7,9 +7,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
|
||||
@@ -3,9 +3,6 @@ on:
|
||||
release:
|
||||
types: [published] # This makes it run only when a new released is published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
latest-release:
|
||||
name: Add/update tag to new release
|
||||
|
||||
@@ -19,9 +19,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-check:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -17,9 +17,6 @@ env:
|
||||
GITHUB_ORG: ${{ github.repository_owner }}
|
||||
GITHUB_REPO: ${{ github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup-expired:
|
||||
name: Clean up expired showtime environments
|
||||
|
||||
@@ -26,9 +26,6 @@ env:
|
||||
GITHUB_REPO: ${{ github.event.repository.name }}
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: 🎪 Sync PR to desired state
|
||||
|
||||
@@ -26,9 +26,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -27,9 +27,6 @@ concurrency:
|
||||
group: helm-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -22,9 +22,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -14,9 +14,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -13,9 +13,6 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
supersetbot:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -5,9 +5,6 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
welcome:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -97,54 +97,6 @@ for more information on how to configure it.
|
||||
|
||||
At the very least, you'll want to change `SECRET_KEY` and `SQLALCHEMY_DATABASE_URI`. Continue reading for more about each of these.
|
||||
|
||||
## Localizing D3 date and time labels
|
||||
|
||||
`BABEL_DEFAULT_LOCALE` controls Superset's application translations, while
|
||||
`D3_TIME_FORMAT` provides localized date and time names to visualizations that
|
||||
use the D3 formatter registry, including Calendar Heatmap. Configure both when
|
||||
you want the application and chart labels to use the same locale.
|
||||
|
||||
`D3_TIME_FORMAT` accepts partial overrides. For example, Russian month names
|
||||
can be configured in `superset_config.py` as follows:
|
||||
|
||||
```python
|
||||
BABEL_DEFAULT_LOCALE = "ru"
|
||||
|
||||
D3_TIME_FORMAT = {
|
||||
"months": [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
],
|
||||
"shortMonths": [
|
||||
"Янв",
|
||||
"Фев",
|
||||
"Мар",
|
||||
"Апр",
|
||||
"Май",
|
||||
"Июн",
|
||||
"Июл",
|
||||
"Авг",
|
||||
"Сен",
|
||||
"Окт",
|
||||
"Ноя",
|
||||
"Дек",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Restart Superset after changing `superset_config.py` so the frontend receives
|
||||
the updated formatter configuration.
|
||||
|
||||
## Chart-data query timing
|
||||
|
||||
Set `CHART_DATA_INCLUDE_TIMING = True` to add an optional versioned timing object
|
||||
|
||||
+5
-49
@@ -46,43 +46,13 @@ import FAQSchema from '@site/src/components/FAQSchema';
|
||||
answer:
|
||||
'You need to register a free account at Mapbox.com, obtain an API key, and add it to your .env file at the key MAPBOX_API_KEY.',
|
||||
},
|
||||
{
|
||||
question: 'How to limit the timed refresh on a dashboard?',
|
||||
answer:
|
||||
'To exclude specific slices from the timed refresh process, add the timed_refresh_immune_slices key to the dashboard JSON Metadata field with the slice IDs to exclude.',
|
||||
},
|
||||
{
|
||||
question: "Why does 'flask fab' or Superset freeze, hang, or not respond when started (my home directory is NFS mounted)?",
|
||||
answer:
|
||||
'By default, Superset creates and uses an SQLite database at ~/.superset/superset.db. SQLite is known to not work well if used on NFS due to broken file locking. Override the path with the SUPERSET_HOME environment variable or change SQLALCHEMY_DATABASE_URI in superset_config.py.',
|
||||
},
|
||||
{
|
||||
question: 'What if the table schema changed?',
|
||||
answer:
|
||||
'Go to Data -> Datasets, click the edit icon next to the dataset whose schema has changed, and hit Sync columns from source from the Columns tab. The new columns will get merged.',
|
||||
},
|
||||
{
|
||||
question: 'What database engine can I use as a backend for Superset?',
|
||||
answer:
|
||||
'Superset is tested using MySQL, PostgreSQL, and SQLite backends for storing its internal metadata. While Superset supports many databases as data sources, only these are recommended for the metadata store in production.',
|
||||
},
|
||||
{
|
||||
question: 'How can I configure OAuth authentication and authorization?',
|
||||
answer:
|
||||
'Take a look at the Flask-AppBuilder OAuth configuration example, which shows how to configure OAuth authentication and authorization in Superset.',
|
||||
},
|
||||
{
|
||||
question: 'Is there a way to force the dashboard to use specific colors?',
|
||||
answer:
|
||||
'It is possible on a per-dashboard basis by providing a mapping of labels to colors in the JSON Metadata attribute using the label_colors key. You can use a full hex color, a named color, or the index in the current color palette.',
|
||||
},
|
||||
{
|
||||
question: 'How do I expand all chart descriptions on a dashboard by default?',
|
||||
answer:
|
||||
'Add the expand_all_slices key to the dashboard JSON Metadata field with a value of true. Charts that have already been manually expanded or collapsed keep that per-chart override regardless of the setting.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset work with [insert database engine here]?',
|
||||
question: 'Does Superset work with my database?',
|
||||
answer:
|
||||
'Superset supports any database with a Python SQLAlchemy dialect and DBAPI driver. Check the Connecting to Databases documentation for the full list of supported databases.',
|
||||
},
|
||||
@@ -91,30 +61,15 @@ import FAQSchema from '@site/src/components/FAQSchema';
|
||||
answer:
|
||||
'Yes, Superset has a public REST API documented using Swagger. Enable FAB_API_SWAGGER_UI in superset_config.py to access interactive API documentation at /swagger/v1.',
|
||||
},
|
||||
{
|
||||
question: 'How can I see usage statistics (e.g., monthly active users)?',
|
||||
answer:
|
||||
'This functionality is not included with Superset, but you can extract and analyze the logs table in Superset\u2019s metadata database to see what actions have occurred.',
|
||||
},
|
||||
{
|
||||
question: 'What does Hours Offset in the Edit Dataset view do?',
|
||||
answer:
|
||||
'In the Edit Dataset view, the hours offset lets you configure the number of hours to be added or subtracted from the time column. This can be used, for example, to convert UTC time to local time.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset collect any telemetry data?',
|
||||
answer:
|
||||
'Superset uses Scarf by default to collect basic telemetry data to help maintainers understand version usage. Users can opt out by setting the SCARF_ANALYTICS environment variable to false.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset have an archive panel or trash bin from which a user can recover deleted assets?',
|
||||
question: 'Does Superset have a trash bin to recover deleted assets?',
|
||||
answer:
|
||||
'No. Currently, there is no way to recover a deleted Superset dashboard, chart, dataset, or database from the UI. It is recommended to take periodic backups of the metadata database and use export functionality for recovery.',
|
||||
},
|
||||
{
|
||||
question: 'I ran a security scan of the Superset container image and it showed dozens of "high" and "critical" vulnerabilities! Can you release a version of Superset without these?',
|
||||
answer:
|
||||
'These are dependency CVEs in software that Superset uses, mostly in the Linux kernel or Python. Superset addresses them by regularly updating dependencies and welcomes pull requests that fix dependency CVEs. The Superset security team focuses primarily on vulnerabilities in Superset itself.',
|
||||
'No, there is no built-in way to recover deleted dashboards, charts, or datasets. It is recommended to take periodic backups of the metadata database and use export functionality for recovery.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -259,7 +214,8 @@ frontend falls back to a built-in default of `5000` milliseconds.
|
||||
SUPERSET_DASHBOARD_MANUAL_REFRESH_STAGGER_MS = 0
|
||||
```
|
||||
|
||||
## Why does `flask fab` or Superset freeze, hang, or not respond when started (my home directory is NFS mounted)?
|
||||
**Why does ‘flask fab’ or superset freeze/hang/not responding when started (my home directory is
|
||||
NFS mounted)?**
|
||||
|
||||
By default, Superset creates and uses an SQLite database at `~/.superset/superset.db`. SQLite is
|
||||
known to [not work well if used on NFS](https://www.sqlite.org/lockingv3.html) due to broken file
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.5.4",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"caniuse-lite": "^1.0.30001807",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.3",
|
||||
|
||||
+4
-4
@@ -6745,10 +6745,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001809:
|
||||
version "1.0.30001809"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz#e6cf71f14ddfe008f114dd2a846923be3c03a07b"
|
||||
integrity sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001807:
|
||||
version "1.0.30001807"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz#a113854941fb45b4c1f51793f4636920489079b4"
|
||||
integrity sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
|
||||
+3
-3
@@ -135,10 +135,10 @@ athena = ["pyathena[pandas]>=3.35.4, <4"]
|
||||
# superset/db_engine_specs/aurora.py's known_incompatibilities metadata.
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.35.1",
|
||||
"pandas-gbq>=0.35.0",
|
||||
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
|
||||
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
|
||||
"sqlalchemy-bigquery>=1.17.2",
|
||||
"sqlalchemy-bigquery>=1.17.1",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
@@ -185,7 +185,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
|
||||
# installing this extra is only required to actually run exports.
|
||||
excel-export = ["boto3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.6,<4.0",
|
||||
"fastmcp>=3.4.5,<4.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
|
||||
@@ -239,9 +239,9 @@ et-xmlfile==2.0.0
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp-slim
|
||||
fastmcp==3.4.7
|
||||
fastmcp==3.4.5
|
||||
# via apache-superset
|
||||
fastmcp-slim==3.4.7
|
||||
fastmcp-slim==3.4.5
|
||||
# via fastmcp
|
||||
filelock==3.20.3
|
||||
# via
|
||||
@@ -640,7 +640,7 @@ pandas==2.3.3
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
# prophet
|
||||
pandas-gbq==0.35.1
|
||||
pandas-gbq==0.35.0
|
||||
# via apache-superset
|
||||
parameterized==0.9.0
|
||||
# via apache-superset
|
||||
@@ -964,7 +964,7 @@ sqlalchemy==2.0.51
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-continuum
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.17.2
|
||||
sqlalchemy-bigquery==1.17.1
|
||||
# via apache-superset
|
||||
sqlalchemy-continuum==1.7.0
|
||||
# via
|
||||
|
||||
Generated
+37
-34
@@ -189,7 +189,7 @@
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
@@ -200,7 +200,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -218,13 +218,13 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -272,7 +272,7 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.12",
|
||||
"tsx": "^4.23.10",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -11833,9 +11833,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
|
||||
"integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
|
||||
"integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
@@ -11851,13 +11851,7 @@
|
||||
"yarn": ">=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=10 <11",
|
||||
"vitest": ">= 0.32"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vitest": {
|
||||
"optional": true
|
||||
}
|
||||
"@testing-library/dom": ">=10 <11"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
@@ -12708,9 +12702,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
@@ -16006,9 +16000,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
|
||||
"integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
|
||||
"version": "2.11.12",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
|
||||
"integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -20146,9 +20140,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.8.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
|
||||
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
|
||||
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -33252,9 +33246,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -34961,9 +34955,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-checkbox-tree/node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -39972,9 +39966,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"version": "4.23.10",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.10.tgz",
|
||||
"integrity": "sha512-0Vb9eKU47njkxv/6B8CRZRDsxNDT/Pz+BIU+M5jw7xL3TdzAjSxlZUxu0xFL/kLpaG3sHZ0LH2wbK1T1yo7CUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43284,7 +43278,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
@@ -43359,6 +43353,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
@@ -277,7 +277,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -295,13 +295,13 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -349,7 +349,7 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.12",
|
||||
"tsx": "^4.23.10",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -415,7 +415,6 @@
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nanoid@>=3 <4": "3.3.18",
|
||||
"nwsapi": "^2.2.13",
|
||||
"puppeteer": "^22.4.1",
|
||||
"tar": "^7.5.16",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Disable capturing Git commit info as the project's history is increasingly dense
|
||||
// and breach Playwright's default 3-seconds `git` command timeout limit
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getSequentialSchemeRegistry } from '@superset-ui/core';
|
||||
import { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import CalHeatMapImport from './vendor/cal-heatmap';
|
||||
import { convertUTCTimestampToLocal, getFormattedUTCTime } from './utils';
|
||||
import { convertUTCTimestampToLocal } from './utils';
|
||||
|
||||
// The vendor file is @ts-nocheck, so its export lacks type info.
|
||||
// Define a minimal constructor interface for use in this file.
|
||||
@@ -103,8 +103,6 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
const subDomainTextFormat = showValues
|
||||
? (_date: Date, value: number) => valueFormatter(value)
|
||||
: null;
|
||||
const dateFormatter = (date: Date, format: string) =>
|
||||
getFormattedUTCTime(date.getTime(), format);
|
||||
|
||||
const metricsData = data.data;
|
||||
|
||||
@@ -168,7 +166,6 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
itemName: '',
|
||||
valueFormatter,
|
||||
timeFormatter,
|
||||
dateFormatter,
|
||||
subDomainTextFormat,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,8 +76,6 @@ var CalHeatMap = function () {
|
||||
|
||||
timeFormatter: d => d,
|
||||
|
||||
dateFormatter: null,
|
||||
|
||||
domain: 'hour',
|
||||
|
||||
subDomain: 'min',
|
||||
@@ -1992,14 +1990,10 @@ CalHeatMap.prototype = {
|
||||
|
||||
if (typeof format === 'function') {
|
||||
return format(d);
|
||||
} else {
|
||||
var f = d3.time.format(format);
|
||||
return f(d);
|
||||
}
|
||||
|
||||
if (typeof this.options.dateFormatter === 'function') {
|
||||
return this.options.dateFormatter(d, format);
|
||||
}
|
||||
|
||||
var f = d3.time.format(format);
|
||||
return f(d);
|
||||
},
|
||||
|
||||
getSubDomainTitle: function (d) {
|
||||
|
||||
@@ -25,11 +25,9 @@ import {
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
|
||||
import { convertUTCTimestampToLocal } from '../src/utils';
|
||||
|
||||
interface MockCalHeatMapConfig {
|
||||
itemSelector: Element;
|
||||
dateFormatter?: (date: Date, format: string) => string;
|
||||
}
|
||||
|
||||
type MetricNameInput = string | string[];
|
||||
@@ -40,7 +38,6 @@ let mockInitCallCount = 0;
|
||||
let mockThrowOnInitCall: number | null = null;
|
||||
let mockDestroyCallCount = 0;
|
||||
let mockDestroyedInstanceIds: string[] = [];
|
||||
let mockDateFormatter: MockCalHeatMapConfig['dateFormatter'];
|
||||
|
||||
const mockTheme = {
|
||||
colorBgElevated: '#ffffff',
|
||||
@@ -59,7 +56,6 @@ jest.mock('../src/vendor/cal-heatmap', () => ({
|
||||
} = require('../src/tooltip');
|
||||
|
||||
mockInitCallCount += 1;
|
||||
mockDateFormatter = config.dateFormatter;
|
||||
if (mockThrowOnInitCall === mockInitCallCount) {
|
||||
throw new Error('Mock CalHeatMap init failure');
|
||||
}
|
||||
@@ -288,28 +284,9 @@ afterEach(() => {
|
||||
mockThrowOnInitCall = null;
|
||||
mockDestroyCallCount = 0;
|
||||
mockDestroyedInstanceIds = [];
|
||||
mockDateFormatter = undefined;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('Calendar provides a timezone-safe date formatter to CalHeatMap', () => {
|
||||
const calendarOwner = document.createElement('div');
|
||||
document.body.appendChild(calendarOwner);
|
||||
|
||||
Calendar(calendarOwner, {
|
||||
...createCalendarProps('localized-metric'),
|
||||
theme: mockTheme,
|
||||
});
|
||||
|
||||
if (!mockDateFormatter) {
|
||||
throw new Error('Expected Calendar to configure a date formatter');
|
||||
}
|
||||
|
||||
const localDate = new Date(convertUTCTimestampToLocal(Date.UTC(2024, 0, 1)));
|
||||
|
||||
expect(mockDateFormatter(localDate, '%Y-%m-%d')).toBe('2024-01-01');
|
||||
});
|
||||
|
||||
test('rerender and unmount clean up only the affected calendar tooltips', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import CalHeatMapImport from '../src/vendor/cal-heatmap';
|
||||
|
||||
type DateFormatter = (date: Date, format: string) => string;
|
||||
type FunctionalDateFormat = (date: Date) => string;
|
||||
|
||||
interface CalHeatMapInstance {
|
||||
options: {
|
||||
dateFormatter: DateFormatter | null;
|
||||
};
|
||||
formatDate(date: Date, format: string | FunctionalDateFormat): string;
|
||||
}
|
||||
|
||||
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
|
||||
|
||||
test('CalHeatMap delegates string date formats to the configured formatter', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
const dateFormatter = jest.fn<string, [Date, string]>(() => 'Январь');
|
||||
calendar.options.dateFormatter = dateFormatter;
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('Январь');
|
||||
expect(dateFormatter).toHaveBeenCalledWith(date, '%B');
|
||||
});
|
||||
|
||||
test('CalHeatMap preserves functional formatters over the configured formatter', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
const dateFormatter = jest.fn<string, [Date, string]>(() => 'localized');
|
||||
const functionalFormat = jest.fn<string, [Date]>(() => 'custom');
|
||||
calendar.options.dateFormatter = dateFormatter;
|
||||
|
||||
expect(calendar.formatDate(date, functionalFormat)).toBe('custom');
|
||||
expect(functionalFormat).toHaveBeenCalledWith(date);
|
||||
expect(dateFormatter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('CalHeatMap keeps the D3 formatter fallback', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('January');
|
||||
});
|
||||
+1
-1
@@ -867,7 +867,7 @@ function DatasourceEditor({
|
||||
return {
|
||||
...metric,
|
||||
certification_details: certificationDetails || details,
|
||||
warning_markdown: warningMarkdown || metric.warning_markdown || '',
|
||||
warning_markdown: warningMarkdown || '',
|
||||
certified_by: certifiedBy || certifiedByMetric,
|
||||
};
|
||||
}),
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { screen, userEvent, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
// Certifying a metric fills two adjacent fields in one visit to the expanded
|
||||
// row. Both are committed through TextControl's debounce, so the second one
|
||||
// used to land on the item as it looked before the first had been applied,
|
||||
// leaving the saved metric with details but no certifier.
|
||||
test('certifying a metric keeps both certified_by and certification_details', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('Certified by'),
|
||||
'Metric Certifier',
|
||||
);
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('Certification details'),
|
||||
'Metric cert details',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const { calls } = testProps.onChange.mock;
|
||||
const savedMetrics = calls[calls.length - 1]?.[0]?.metrics ?? [];
|
||||
const saved = savedMetrics.find(metric => metric.metric_name === 'count');
|
||||
expect(saved).toEqual(
|
||||
expect.objectContaining({
|
||||
certified_by: 'Metric Certifier',
|
||||
certification_details: 'Metric cert details',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
// Stub the Ace-backed control with a plain textarea. Ace spreads its document
|
||||
// across many spans and keeps only the keystroke buffer in its own textarea,
|
||||
// so asserting on the value the control receives is less brittle than
|
||||
// reaching into Ace's DOM.
|
||||
jest.mock('src/explore/components/controls/TextAreaControl', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
controlId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
controlId?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) => (
|
||||
<textarea
|
||||
data-test={`mock-textarea-${controlId}`}
|
||||
value={value ?? ''}
|
||||
onChange={event => onChange?.(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
// Regression test for #42704. Explore's datasource payload (SqlMetric.data on
|
||||
// the backend) exposes warning_markdown as a flattened top-level field and
|
||||
// omits the raw `extra` JSON string that the /api/v1/dataset/{id} endpoint
|
||||
// backing the Datasets page provides. Deriving warning_markdown purely from
|
||||
// `extra` therefore dropped the saved text when the modal was opened from
|
||||
// Explore, leaving the Warning field blank on reopen.
|
||||
test('keeps a pre-existing top-level warning_markdown when the metric has no extra', async () => {
|
||||
const baseProps = createProps();
|
||||
const testProps = {
|
||||
...baseProps,
|
||||
datasource: {
|
||||
...baseProps.datasource,
|
||||
metrics: [
|
||||
{
|
||||
...baseProps.datasource.metrics[0],
|
||||
warning_markdown: 'existing warning',
|
||||
extra: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
const metricsTab = await screen.findByTestId('collection-tab-Metrics');
|
||||
await userEvent.click(metricsTab);
|
||||
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('mock-textarea-warning_markdown'),
|
||||
).toHaveValue('existing warning');
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { Divider, Form, Typography } from '@superset-ui/core/components';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { recurseReactClone } from '../../utils';
|
||||
@@ -39,24 +39,14 @@ export default function Fieldset({
|
||||
title = null,
|
||||
compact = false,
|
||||
}: FieldsetProps) {
|
||||
// Controls report their edits asynchronously - TextControl debounces by
|
||||
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
|
||||
// earlier render. Spreading that render's `item` rebuilds the whole record
|
||||
// from a snapshot taken before a sibling field committed, dropping the value
|
||||
// the user typed first. Reading off a ref merges into the latest commit.
|
||||
const itemRef = useRef(item);
|
||||
useEffect(() => {
|
||||
itemRef.current = item;
|
||||
}, [item]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(fieldKey: fieldKeyType, val: any) => {
|
||||
onChange?.({
|
||||
...itemRef.current,
|
||||
...item,
|
||||
[fieldKey]: val,
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
[onChange, item],
|
||||
);
|
||||
|
||||
const propExtender = (field: { props: { fieldKey: fieldKeyType } }) => ({
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { Dispatch } from 'redux';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { cloneDeep, omit } from 'lodash-es';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions';
|
||||
import { HYDRATE_DASHBOARD } from './hydrate';
|
||||
import {
|
||||
@@ -90,20 +90,12 @@ export const setFilterConfiguration =
|
||||
});
|
||||
try {
|
||||
const response = await updateFilters(filterChanges);
|
||||
// chartsInScope/tabsInScope are derived from the live layout, and the
|
||||
// response carries the persisted copy for every filter - including the
|
||||
// ones this save never touched, whose copy is whatever was stored when
|
||||
// the dashboard was last saved. Dropping them lets the reducers keep the
|
||||
// scopes calculateScopes already computed for this session.
|
||||
const savedFilters = response.result.map(
|
||||
filter => omit(filter, ['chartsInScope', 'tabsInScope']) as Filter,
|
||||
);
|
||||
dispatch({
|
||||
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
|
||||
filterChanges: savedFilters,
|
||||
filterChanges: response.result,
|
||||
deletedIds: filterChanges.deleted,
|
||||
});
|
||||
dispatch(nativeFiltersConfigChanged(savedFilters));
|
||||
dispatch(nativeFiltersConfigChanged(response.result));
|
||||
dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters));
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
|
||||
Generated
+12
-12
@@ -15,18 +15,18 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.3"
|
||||
"ws": "^8.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
@@ -1044,9 +1044,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1689,9 +1689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.8.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
|
||||
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
|
||||
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -3520,9 +3520,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
|
||||
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -23,18 +23,18 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.3"
|
||||
"ws": "^8.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
|
||||
@@ -34,7 +34,6 @@ from superset.commands.dashboard.exceptions import (
|
||||
DashboardUpdateFailedError,
|
||||
)
|
||||
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
|
||||
from superset.dashboards.filter_scope import derive_metadata_scopes
|
||||
from superset.dashboards.filters import DashboardAccessFilter
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import db
|
||||
@@ -548,9 +547,7 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
cls, id: str
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
dashboard = cls.get_by_id_or_slug(id)
|
||||
metadata = derive_metadata_scopes(
|
||||
dashboard, json.loads(dashboard.json_metadata or "{}")
|
||||
)
|
||||
metadata = json.loads(dashboard.json_metadata or "{}")
|
||||
native_filter_configuration = metadata.get("native_filter_configuration", [])
|
||||
|
||||
tab_filters = defaultdict(list)
|
||||
@@ -620,13 +617,6 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
metadata["native_filter_configuration"] = updated_configuration
|
||||
dashboard.json_metadata = json.dumps(metadata)
|
||||
|
||||
# The client rebuilds its in-scope state from this response, so hand
|
||||
# back derived scopes rather than the stored caches, which are stale
|
||||
# for every filter the caller did not touch.
|
||||
updated_configuration = derive_metadata_scopes(dashboard, metadata)[
|
||||
"native_filter_configuration"
|
||||
]
|
||||
|
||||
return updated_configuration
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -91,7 +91,6 @@ from superset.commands.importers.v1.utils import get_contents_from_bundle
|
||||
from superset.commands.purge import PurgeArchivedCommand, SoftDeleteBinding
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO
|
||||
from superset.dashboards.filter_scope import derive_json_metadata
|
||||
from superset.dashboards.filters import (
|
||||
DashboardAccessFilter,
|
||||
DashboardCertifiedFilter,
|
||||
@@ -654,12 +653,6 @@ class DashboardRestApi(
|
||||
schema = self.dashboard_get_response_schema
|
||||
|
||||
result = schema.dump(dash)
|
||||
if json_metadata := result.get("json_metadata"):
|
||||
# The stored scope caches (``chartsInScope``, ``tabsInScope``,
|
||||
# ``chart_configuration``) go stale as soon as the layout changes;
|
||||
# derive them so callers see the same document the dashboard client
|
||||
# computes for itself.
|
||||
result["json_metadata"] = derive_json_metadata(dash, json_metadata)
|
||||
if "charts" in result:
|
||||
# Only name the member charts the caller can access, consistent with
|
||||
# the per-object narrowing applied to the dashboard's datasets and
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Derive filter-scope caches in ``json_metadata`` from the dashboard layout.
|
||||
|
||||
``chartsInScope`` / ``tabsInScope`` on a native filter, and the ``chartsInScope``
|
||||
lists inside ``chart_configuration`` / ``global_chart_configuration``, are
|
||||
denormalized caches of the authoritative ``scope`` plus ``position_json``. They
|
||||
are written when a dashboard is saved and are never revisited afterwards, so a
|
||||
dashboard that has charts added or removed - or that was seeded, exported or
|
||||
imported - carries scope arrays naming charts it does not contain.
|
||||
|
||||
The dashboard client already ignores the stored values and recomputes them from
|
||||
the live layout on every load, which is why the JSON Metadata panel and
|
||||
``GET /api/v1/dashboard/{id}`` disagreed on a dashboard nobody had ever saved.
|
||||
Deriving them on read makes the API agree with the client and keeps integrations
|
||||
that read ``native_filter_configuration`` from receiving dangling chart ids.
|
||||
|
||||
The rules mirror the client (``superset-frontend/src/dashboard/util``):
|
||||
``calculateScopes``, ``getChartIdsInFilterScope``, ``findTabsWithChartsInScope``
|
||||
and ``getCrossFiltersConfiguration``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from superset.utils import json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
CHART_TYPE = "CHART"
|
||||
TAB_TYPE = "TAB"
|
||||
NATIVE_FILTER_DIVIDER_PREFIX = "NATIVE_FILTER_DIVIDER-"
|
||||
DIVIDER_TYPES = frozenset({"DIVIDER", "CHART_CUSTOMIZATION_DIVIDER"})
|
||||
|
||||
# ``chart-<chartId>-layer-<layerIndex>``, the per-layer scope keys a deck.gl
|
||||
# multi-layer chart contributes to ``scope.selectedLayers``.
|
||||
LAYER_SELECTION_RE = re.compile(r"^chart-(\d+)-layer-(\d+)$")
|
||||
|
||||
ChartLayoutItems = dict[int, list[dict[str, Any]]]
|
||||
|
||||
|
||||
def build_chart_layout_items(position_data: dict[str, Any]) -> ChartLayoutItems:
|
||||
"""Map each chart id in the layout to the layout items that render it."""
|
||||
chart_layout_items: ChartLayoutItems = {}
|
||||
for item in position_data.values():
|
||||
if not isinstance(item, dict) or item.get("type") != CHART_TYPE:
|
||||
continue
|
||||
chart_id = item.get("meta", {}).get("chartId")
|
||||
if isinstance(chart_id, int):
|
||||
chart_layout_items.setdefault(chart_id, []).append(item)
|
||||
return chart_layout_items
|
||||
|
||||
|
||||
def get_chart_ids_in_scope(
|
||||
scope: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[int]:
|
||||
"""Charts covered by ``scope``, in ``chart_ids`` order."""
|
||||
excluded = set(scope.get("excluded") or [])
|
||||
root_path = set(scope.get("rootPath") or [])
|
||||
|
||||
def in_scope(chart_id: int) -> bool:
|
||||
if chart_id in excluded:
|
||||
return False
|
||||
return any(
|
||||
parent in root_path
|
||||
for layout_item in chart_layout_items.get(chart_id, [])
|
||||
for parent in layout_item.get("parents") or []
|
||||
)
|
||||
|
||||
selected_layers = scope.get("selectedLayers") or []
|
||||
if not selected_layers:
|
||||
return [chart_id for chart_id in chart_ids if in_scope(chart_id)]
|
||||
|
||||
# A layer selection targets its chart directly, and suppresses the
|
||||
# rootPath/excluded test for that chart.
|
||||
charts_with_layer_selections = set()
|
||||
targeted: list[int] = []
|
||||
chart_id_set = set(chart_ids)
|
||||
for selection_key in selected_layers:
|
||||
if match := LAYER_SELECTION_RE.match(str(selection_key)):
|
||||
chart_id = int(match.group(1))
|
||||
charts_with_layer_selections.add(chart_id)
|
||||
if chart_id in chart_id_set and chart_id not in targeted:
|
||||
targeted.append(chart_id)
|
||||
|
||||
return targeted + [
|
||||
chart_id
|
||||
for chart_id in chart_ids
|
||||
if chart_id not in charts_with_layer_selections
|
||||
and chart_id not in targeted
|
||||
and in_scope(chart_id)
|
||||
]
|
||||
|
||||
|
||||
def get_tabs_in_scope(
|
||||
charts_in_scope: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[str]:
|
||||
"""Tabs holding at least one of ``charts_in_scope``."""
|
||||
tabs_in_scope: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for chart_id in charts_in_scope:
|
||||
for layout_item in chart_layout_items.get(chart_id, []):
|
||||
for parent in layout_item.get("parents") or []:
|
||||
if parent.startswith(f"{TAB_TYPE}-") and parent not in seen:
|
||||
seen.add(parent)
|
||||
tabs_in_scope.append(parent)
|
||||
return tabs_in_scope
|
||||
|
||||
|
||||
def _is_divider(item: dict[str, Any]) -> bool:
|
||||
return (
|
||||
str(item.get("id", "")).startswith(NATIVE_FILTER_DIVIDER_PREFIX)
|
||||
or item.get("type") in DIVIDER_TYPES
|
||||
)
|
||||
|
||||
|
||||
def _derive_item_scopes(
|
||||
items: list[Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> list[Any]:
|
||||
"""Restamp ``chartsInScope`` / ``tabsInScope`` on scoped config items.
|
||||
|
||||
Items without a usable ``scope`` are returned untouched: legacy chart
|
||||
customizations target a chart directly and only gain a ``scope`` once the
|
||||
client migrates them, so overwriting their cache here would drop targeting
|
||||
the client still needs.
|
||||
"""
|
||||
derived = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
derived.append(item)
|
||||
continue
|
||||
if _is_divider(item):
|
||||
derived.append({**item, "chartsInScope": [], "tabsInScope": []})
|
||||
continue
|
||||
scope = item.get("scope")
|
||||
if not isinstance(scope, dict) or not isinstance(scope.get("excluded"), list):
|
||||
derived.append(item)
|
||||
continue
|
||||
charts_in_scope = get_chart_ids_in_scope(scope, chart_ids, chart_layout_items)
|
||||
derived.append(
|
||||
{
|
||||
**item,
|
||||
"chartsInScope": charts_in_scope,
|
||||
"tabsInScope": get_tabs_in_scope(charts_in_scope, chart_layout_items),
|
||||
}
|
||||
)
|
||||
return derived
|
||||
|
||||
|
||||
def _derive_cross_filter_scopes(
|
||||
metadata: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
chart_layout_items: ChartLayoutItems,
|
||||
) -> None:
|
||||
global_config = metadata.get("global_chart_configuration")
|
||||
global_charts_in_scope = chart_ids
|
||||
if isinstance(global_config, dict) and isinstance(global_config.get("scope"), dict):
|
||||
global_charts_in_scope = get_chart_ids_in_scope(
|
||||
global_config["scope"], chart_ids, chart_layout_items
|
||||
)
|
||||
metadata["global_chart_configuration"] = {
|
||||
**global_config,
|
||||
"chartsInScope": global_charts_in_scope,
|
||||
}
|
||||
|
||||
chart_configuration = metadata.get("chart_configuration")
|
||||
if not isinstance(chart_configuration, dict):
|
||||
return
|
||||
|
||||
derived_configuration = {}
|
||||
for key, config in chart_configuration.items():
|
||||
try:
|
||||
chart_id = int(key)
|
||||
except (TypeError, ValueError):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
# Config for a chart no longer on the dashboard is dead weight; the
|
||||
# client drops it on load for the same reason.
|
||||
if chart_id not in chart_layout_items:
|
||||
continue
|
||||
if not isinstance(config, dict):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
cross_filters = config.get("crossFilters")
|
||||
if not isinstance(cross_filters, dict):
|
||||
derived_configuration[key] = config
|
||||
continue
|
||||
scope = cross_filters.get("scope")
|
||||
if isinstance(scope, dict):
|
||||
charts_in_scope = get_chart_ids_in_scope(
|
||||
scope, chart_ids, chart_layout_items
|
||||
)
|
||||
else:
|
||||
# Anything that is not an explicit scope object points at the
|
||||
# dashboard-wide scope, which never includes the emitting chart.
|
||||
charts_in_scope = [cid for cid in global_charts_in_scope if cid != chart_id]
|
||||
derived_configuration[key] = {
|
||||
**config,
|
||||
"crossFilters": {**cross_filters, "chartsInScope": charts_in_scope},
|
||||
}
|
||||
|
||||
metadata["chart_configuration"] = derived_configuration
|
||||
|
||||
|
||||
def derive_scopes(
|
||||
metadata: dict[str, Any],
|
||||
position_data: dict[str, Any],
|
||||
chart_ids: list[int],
|
||||
) -> dict[str, Any]:
|
||||
"""Return ``metadata`` with every derived scope cache recomputed.
|
||||
|
||||
``chart_ids`` orders the resulting ``chartsInScope`` arrays and should be the
|
||||
dashboard's chart ids as the client sees them, so that the API and the JSON
|
||||
Metadata panel produce byte-identical documents.
|
||||
"""
|
||||
derived = dict(metadata)
|
||||
chart_layout_items = build_chart_layout_items(position_data)
|
||||
|
||||
for key in ("native_filter_configuration", "chart_customization_config"):
|
||||
config = derived.get(key)
|
||||
if isinstance(config, list):
|
||||
derived[key] = _derive_item_scopes(config, chart_ids, chart_layout_items)
|
||||
|
||||
_derive_cross_filter_scopes(derived, chart_ids, chart_layout_items)
|
||||
return derived
|
||||
|
||||
|
||||
def derive_metadata_scopes(
|
||||
dashboard: Dashboard,
|
||||
metadata: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""``derive_scopes`` for a dashboard model's parsed ``json_metadata``."""
|
||||
return derive_scopes(
|
||||
metadata,
|
||||
dashboard.position,
|
||||
[slc.id for slc in dashboard.slices],
|
||||
)
|
||||
|
||||
|
||||
def derive_json_metadata(dashboard: Dashboard, json_metadata: str) -> str:
|
||||
"""``derive_metadata_scopes`` over a raw ``json_metadata`` string.
|
||||
|
||||
Metadata that does not parse as a JSON object is handed back untouched -
|
||||
reading a dashboard is not the place to start rejecting documents that have
|
||||
always been served as-is.
|
||||
"""
|
||||
try:
|
||||
metadata = json.loads(json_metadata)
|
||||
except (TypeError, ValueError):
|
||||
return json_metadata
|
||||
if not isinstance(metadata, dict):
|
||||
return json_metadata
|
||||
return json.dumps(derive_metadata_scopes(dashboard, metadata))
|
||||
@@ -165,31 +165,12 @@ def get_available_engine_specs() -> dict[type[BaseEngineSpec], set[str]]: # noq
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.debug("Unable to load SQLAlchemy dialect %s: %s", ep.name, ex)
|
||||
else:
|
||||
# A third-party entry point can load successfully yet not resolve to
|
||||
# a usable dialect. Validate the same dialect contract as the native
|
||||
# loop so malformed connectors are neither advertised nor allowed to
|
||||
# abort the whole enumeration.
|
||||
backend = getattr(dialect, "name", None)
|
||||
if (
|
||||
not isinstance(dialect, type)
|
||||
or not issubclass(dialect, DefaultDialect)
|
||||
or not isinstance(backend, (str, bytes))
|
||||
or not hasattr(dialect, "driver")
|
||||
or dialect.driver == "adodbapi"
|
||||
):
|
||||
logger.warning(
|
||||
"Skipping SQLAlchemy dialect entry point %r: %r did not "
|
||||
"resolve to a usable dialect (%r)",
|
||||
ep.name,
|
||||
ep.value,
|
||||
dialect,
|
||||
)
|
||||
continue
|
||||
backend = dialect.name
|
||||
if isinstance(backend, bytes):
|
||||
backend = backend.decode()
|
||||
backend = backend_replacements.get(backend, backend)
|
||||
|
||||
driver = dialect.driver
|
||||
driver = getattr(dialect, "driver", dialect.name)
|
||||
if isinstance(driver, bytes):
|
||||
driver = driver.decode()
|
||||
drivers[backend].add(driver)
|
||||
|
||||
@@ -646,65 +646,6 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboard_derives_stale_filter_scope(self):
|
||||
"""
|
||||
Dashboard API: ``chartsInScope`` is derived from the layout, not read
|
||||
back from the stored cache (sc-116923).
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
slices = db.session.query(Slice).limit(2).all()
|
||||
positions = {
|
||||
"ROOT_ID": {"id": "ROOT_ID", "type": "ROOT", "children": ["GRID_ID"]},
|
||||
"GRID_ID": {"id": "GRID_ID", "type": "GRID", "parents": ["ROOT_ID"]},
|
||||
}
|
||||
for slc in slices:
|
||||
positions[f"CHART-{slc.id}"] = {
|
||||
"id": f"CHART-{slc.id}",
|
||||
"type": "CHART",
|
||||
"meta": {"chartId": slc.id},
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
}
|
||||
# A scope naming charts the dashboard does not contain - the state every
|
||||
# seeded and imported dashboard starts in.
|
||||
stored_metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"name": "Region",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [90001, 90002],
|
||||
"tabsInScope": ["TAB-gone"],
|
||||
}
|
||||
]
|
||||
}
|
||||
dashboard = self.insert_dashboard(
|
||||
"scope-cache",
|
||||
"scope-cache",
|
||||
[admin.id],
|
||||
slices=slices,
|
||||
position_json=json.dumps(positions),
|
||||
json_metadata=json.dumps(stored_metadata),
|
||||
)
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.get_assert_metric(f"api/v1/dashboard/{dashboard.id}", "get")
|
||||
assert rv.status_code == 200
|
||||
|
||||
response_metadata = json.loads(
|
||||
json.loads(rv.data.decode("utf-8"))["result"]["json_metadata"]
|
||||
)
|
||||
native_filter = response_metadata["native_filter_configuration"][0]
|
||||
assert sorted(native_filter["chartsInScope"]) == sorted(
|
||||
slc.id for slc in slices
|
||||
)
|
||||
assert native_filter["tabsInScope"] == []
|
||||
assert native_filter["name"] == "Region"
|
||||
# Deriving is read-only; the stored document is left alone.
|
||||
assert json.loads(dashboard.json_metadata) == stored_metadata
|
||||
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboard_with_columns(self):
|
||||
"""
|
||||
Dashboard API: Test get dashboard with column selection via q param
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from superset.dashboards.filter_scope import (
|
||||
derive_json_metadata,
|
||||
derive_metadata_scopes,
|
||||
derive_scopes,
|
||||
)
|
||||
from superset.utils import json
|
||||
|
||||
# Two charts in a tab, one chart outside it.
|
||||
POSITION_DATA: dict[str, Any] = {
|
||||
"ROOT_ID": {"id": "ROOT_ID", "type": "ROOT", "children": ["GRID_ID"]},
|
||||
"CHART-outside": {
|
||||
"id": "CHART-outside",
|
||||
"type": "CHART",
|
||||
"meta": {"chartId": 1},
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
},
|
||||
"CHART-in-tab": {
|
||||
"id": "CHART-in-tab",
|
||||
"type": "CHART",
|
||||
"meta": {"chartId": 2},
|
||||
"parents": ["ROOT_ID", "GRID_ID", "TABS-1", "TAB-1"],
|
||||
},
|
||||
"CHART-also-in-tab": {
|
||||
"id": "CHART-also-in-tab",
|
||||
"type": "CHART",
|
||||
"meta": {"chartId": 3},
|
||||
"parents": ["ROOT_ID", "GRID_ID", "TABS-1", "TAB-1"],
|
||||
},
|
||||
"MARKDOWN-1": {"id": "MARKDOWN-1", "type": "MARKDOWN", "parents": ["ROOT_ID"]},
|
||||
}
|
||||
CHART_IDS = [1, 2, 3]
|
||||
|
||||
|
||||
def test_stale_charts_in_scope_is_replaced() -> None:
|
||||
"""The reported symptom: a scope cache naming charts that are not present."""
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [7, 17, 23],
|
||||
"tabsInScope": ["TAB-gone"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [1, 2, 3]
|
||||
assert derived["native_filter_configuration"][0]["tabsInScope"] == ["TAB-1"]
|
||||
|
||||
|
||||
def test_scope_narrowed_to_a_tab() -> None:
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {"rootPath": ["TAB-1"], "excluded": [3]},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [2]
|
||||
assert derived["native_filter_configuration"][0]["tabsInScope"] == ["TAB-1"]
|
||||
|
||||
|
||||
def test_dividers_and_unscoped_items() -> None:
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{"id": "NATIVE_FILTER_DIVIDER-1", "chartsInScope": [7], "tabsInScope": []},
|
||||
{"id": "DIVIDER-1", "type": "DIVIDER", "chartsInScope": [7]},
|
||||
# A legacy chart customization targets a chart directly and only
|
||||
# gains a scope once the client migrates it.
|
||||
{"id": "CHART_CUSTOMIZATION-1", "chartId": 7, "chartsInScope": [7]},
|
||||
]
|
||||
}
|
||||
|
||||
config = derive_scopes(metadata, POSITION_DATA, CHART_IDS)[
|
||||
"native_filter_configuration"
|
||||
]
|
||||
|
||||
assert config[0]["chartsInScope"] == []
|
||||
assert config[1]["chartsInScope"] == []
|
||||
assert config[2]["chartsInScope"] == [7]
|
||||
|
||||
|
||||
def test_chart_configuration_drops_charts_not_on_the_dashboard() -> None:
|
||||
metadata = {
|
||||
"global_chart_configuration": {
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [7, 17],
|
||||
},
|
||||
"chart_configuration": {
|
||||
"1": {"id": 1, "crossFilters": {"scope": "global", "chartsInScope": [17]}},
|
||||
"2": {
|
||||
"id": 2,
|
||||
"crossFilters": {
|
||||
"scope": {"rootPath": ["TAB-1"], "excluded": []},
|
||||
"chartsInScope": [23],
|
||||
},
|
||||
},
|
||||
"84": {"id": 84, "crossFilters": {"scope": "global", "chartsInScope": [7]}},
|
||||
},
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
assert derived["global_chart_configuration"]["chartsInScope"] == [1, 2, 3]
|
||||
assert list(derived["chart_configuration"]) == ["1", "2"]
|
||||
# A globally scoped chart emits to every other chart, never to itself.
|
||||
assert derived["chart_configuration"]["1"]["crossFilters"]["chartsInScope"] == [
|
||||
2,
|
||||
3,
|
||||
]
|
||||
assert derived["chart_configuration"]["2"]["crossFilters"]["chartsInScope"] == [
|
||||
2,
|
||||
3,
|
||||
]
|
||||
|
||||
|
||||
def test_selected_layers_target_their_chart_directly() -> None:
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {
|
||||
"rootPath": ["TAB-1"],
|
||||
"excluded": [1],
|
||||
"selectedLayers": ["chart-1-layer-0"],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
# Chart 1 is excluded and outside the rootPath, but a layer selection wins.
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_key_order_and_untouched_keys_are_kept() -> None:
|
||||
metadata = {
|
||||
"color_scheme": "supersetColors",
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"chartsInScope": [7],
|
||||
"name": "Region",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
}
|
||||
],
|
||||
"refresh_frequency": 0,
|
||||
}
|
||||
|
||||
derived = derive_scopes(metadata, POSITION_DATA, CHART_IDS)
|
||||
|
||||
assert list(derived) == list(metadata)
|
||||
assert list(derived["native_filter_configuration"][0]) == [
|
||||
"id",
|
||||
"chartsInScope",
|
||||
"name",
|
||||
"scope",
|
||||
"tabsInScope",
|
||||
]
|
||||
assert derived["color_scheme"] == "supersetColors"
|
||||
assert derived["refresh_frequency"] == 0
|
||||
|
||||
|
||||
def test_derive_metadata_scopes_orders_by_dashboard_charts() -> None:
|
||||
dashboard = SimpleNamespace(
|
||||
position=POSITION_DATA,
|
||||
slices=[SimpleNamespace(id=3), SimpleNamespace(id=1), SimpleNamespace(id=2)],
|
||||
)
|
||||
metadata = {
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
derived = derive_metadata_scopes(dashboard, metadata) # type: ignore[arg-type]
|
||||
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [3, 1, 2]
|
||||
|
||||
|
||||
def test_derive_json_metadata_round_trip() -> None:
|
||||
dashboard = SimpleNamespace(position=POSITION_DATA, slices=[SimpleNamespace(id=1)])
|
||||
stored = json.dumps(
|
||||
{
|
||||
"native_filter_configuration": [
|
||||
{
|
||||
"id": "NATIVE_FILTER-1",
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
||||
"chartsInScope": [61, 62],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
derived = json.loads(derive_json_metadata(dashboard, stored)) # type: ignore[arg-type]
|
||||
|
||||
assert derived["native_filter_configuration"][0]["chartsInScope"] == [1]
|
||||
|
||||
|
||||
def test_derive_json_metadata_passes_through_unparsable_metadata() -> None:
|
||||
dashboard = SimpleNamespace(position={}, slices=[])
|
||||
|
||||
assert derive_json_metadata(dashboard, "not json") == "not json" # type: ignore[arg-type]
|
||||
assert derive_json_metadata(dashboard, "[]") == "[]" # type: ignore[arg-type]
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine.default import DefaultDialect
|
||||
|
||||
from superset.db_engine_specs import get_available_engine_specs
|
||||
|
||||
@@ -51,92 +50,6 @@ def test_get_available_engine_specs(mocker: MockerFixture) -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_get_available_engine_specs_skips_malformed_dialect_entry_point(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
A third-party ``sqlalchemy.dialects`` entry point that loads successfully but
|
||||
does not resolve to a usable dialect (e.g. a module with no ``name`` or a
|
||||
named class that does not implement the dialect contract) must be skipped.
|
||||
|
||||
Regression test: an unguarded ``dialect.name`` there aborted the whole
|
||||
enumeration with ``AttributeError``, which 500s every page that builds the
|
||||
bootstrap payload (e.g. ``/welcome/``), not just that one connector.
|
||||
"""
|
||||
import types
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.load_engine_specs",
|
||||
return_value=iter([]),
|
||||
)
|
||||
|
||||
malformed_ep = mocker.MagicMock()
|
||||
malformed_ep.name = "bogus"
|
||||
malformed_ep.value = "bogus_pkg:base"
|
||||
# ``ep.load()`` returns a module (no ``name`` attribute), as a real
|
||||
# ``name = pkg:submodule`` entry point would.
|
||||
malformed_ep.load.return_value = types.ModuleType("bogus_pkg.base")
|
||||
|
||||
named_but_invalid_ep = mocker.MagicMock()
|
||||
named_but_invalid_ep.name = "named_bogus"
|
||||
named_but_invalid_ep.value = "bogus_pkg:NamedButInvalidDialect"
|
||||
named_but_invalid_ep.load.return_value = type(
|
||||
"NamedButInvalidDialect",
|
||||
(),
|
||||
{"name": "bogus", "driver": "bogus"},
|
||||
)
|
||||
|
||||
def entry_points(group: str) -> list[object]:
|
||||
return (
|
||||
[malformed_ep, named_but_invalid_ep]
|
||||
if group == "sqlalchemy.dialects"
|
||||
else []
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.entry_points",
|
||||
side_effect=entry_points,
|
||||
)
|
||||
warning = mocker.patch("superset.db_engine_specs.logger.warning")
|
||||
|
||||
# Must not raise (previously ``AttributeError`` on ``dialect.name``).
|
||||
available = get_available_engine_specs()
|
||||
|
||||
assert isinstance(available, dict)
|
||||
# The malformed entry point is skipped with a warning that identifies it.
|
||||
assert any("bogus" in str(call) for call in warning.call_args_list)
|
||||
assert any("named_bogus" in str(call) for call in warning.call_args_list)
|
||||
|
||||
|
||||
def test_get_available_engine_specs_keeps_valid_third_party_dialect(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A valid SQLAlchemy 2.0-style dialect is included without calling dbapi()."""
|
||||
import sqlalchemy.dialects
|
||||
|
||||
from superset.db_engine_specs.sqlite import SqliteEngineSpec
|
||||
|
||||
class ValidDialect(DefaultDialect):
|
||||
name = "sqlite"
|
||||
driver = "valid_driver"
|
||||
|
||||
mocker.patch.object(sqlalchemy.dialects, "__all__", [])
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.load_engine_specs",
|
||||
return_value=iter([SqliteEngineSpec]),
|
||||
)
|
||||
entry_point = mocker.MagicMock()
|
||||
entry_point.load.return_value = ValidDialect
|
||||
mocker.patch(
|
||||
"superset.db_engine_specs.entry_points",
|
||||
return_value=[entry_point],
|
||||
)
|
||||
|
||||
available = get_available_engine_specs()
|
||||
|
||||
assert available[SqliteEngineSpec] == {"valid_driver"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"app",
|
||||
[{"DBS_AVAILABLE_DENYLIST": {"databricks": {"pyhive", "pyodbc"}}}],
|
||||
|
||||
Reference in New Issue
Block a user