Compare commits

...
Author SHA1 Message Date
Enzo Martellucci 5bfc52b5cc fix(explore): keep the row count label visible when the row limit is reached 2026-08-18 10:41:26 +02:00
Elizabeth ThompsonandClaude Opus 4.8 98136d547c fix(reports): fail closed on alert screenshot capture instead of delivering a blank (#43031)
Signed-off-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 00:03:15 -04:00
Elizabeth ThompsonandClaude Opus 4.8 e2070d79dc fix(reports): wait for ECharts paint before capturing report screenshots (#43077)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 00:02:36 -04:00
ʈᵃᵢ 2807f1b0e8 fix(dashboard): report the real error when saving a dashboard fails (#43140) 2026-08-17 17:51:28 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c9c230142b chore(deps): bump baseline-browser-mapping from 2.11.12 to 2.11.13 in /docs (#43243)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 17:46:24 -07:00
Beto Dealmeida 2f8875aaef feat(semantic layers): optional metadata for metrics/dimensions (#43269) 2026-08-17 17:34:17 -07:00
Elizabeth Thompson 6e270df4a2 fix(sql-lab): catch TemplateError in StreamingSqlResultExportCommand.validate (#43226) 2026-08-17 15:02:11 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 97eafd6140 chore(deps-dev): bump @types/node from 26.1.2 to 26.2.0 in /superset-websocket (#43240)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:34:35 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3ed97f9691 chore(deps): bump ws from 8.21.2 to 8.21.3 in /superset-websocket (#43241)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:34:31 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5105899810 chore(deps-dev): bump eslint from 10.8.0 to 10.8.1 in /superset-websocket (#43242)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:34:25 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d917071708 chore(deps): bump caniuse-lite from 1.0.30001807 to 1.0.30001809 in /docs (#43244)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:34:18 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> afde126d9a chore(deps-dev): bump @testing-library/jest-dom from 7.0.0 to 7.0.1 in /superset-frontend (#43246)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:34:15 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b3a9b9beb4 chore(deps-dev): bump @types/node from 26.1.2 to 26.2.0 in /superset-frontend (#43247)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:34:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 70ba9c9552 chore(deps-dev): bump eslint from 10.8.0 to 10.8.1 in /superset-frontend (#43248)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:34:06 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 98276cd1f3 chore(deps-dev): bump baseline-browser-mapping from 2.11.12 to 2.11.13 in /superset-frontend (#43249)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:34:03 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cdeca0c179 chore(deps-dev): bump tsx from 4.23.10 to 4.23.12 in /superset-frontend (#43250)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:33:59 -07:00
Amin Ghadersohi aaf9eba161 fix(db_engine_specs): skip malformed third-party dialect entry points (#43110)
Signed-off-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-08-17 14:10:06 -07:00
Mehmet Salih Yavuz 1991e3f0d2 fix(datasets): keep metric certified_by when certification details are typed next (#43255) 2026-08-17 19:23:31 +03:00
Mehmet Salih Yavuz cfd40bdd0d fix(dashboard): derive filter scope on read instead of serving a stale cache (#43252) 2026-08-17 19:23:19 +03:00
Endi Monan a8216e3787 fix(calendar): localize date labels (#43163) 2026-08-17 09:44:14 -03:00
anopsandEvan Rusackas d114eb638b fix(datasets): preserve metric warning_markdown when extra is absent (#42763)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-16 00:22:24 -07:00
Abdrihman Hussein 70d06e3d77 docs(faq): convert flask fab freeze question to a proper heading (#43221) 2026-08-15 23:21:13 +07:00
Joe Li a2c004266b fix(ci): update vulnerable transitive nanoid (#43166) 2026-08-15 23:09:19 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Đỗ Trọng Hải
2773bc94eb chore(deps-dev): bump sqlalchemy-bigquery from 1.17.1 to 1.17.2 (#43209)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-08-15 00:59:55 -07:00
94459ae885 ci: declare top-level permissions on the remaining workflows (#43197)
Co-authored-by: bikashJMV <bikash@jmv.co.in>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-08-15 14:48:43 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ad3103fdb0 chore(deps-dev): bump fastmcp from 3.4.5 to 3.4.7 (#43210)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-15 00:43:03 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
065342f8c9 chore(deps-dev): bump pandas-gbq from 0.35.0 to 0.35.1 (#43212)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-15 00:42:19 -07:00
63 changed files with 1990 additions and 173 deletions
@@ -21,6 +21,9 @@ on:
#schedule:
# - cron: '0 0 * * *' # Runs daily at midnight UTC
permissions:
contents: read
jobs:
bump-python-package:
runs-on: ubuntu-slim
@@ -16,6 +16,9 @@ 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
+3
View File
@@ -14,6 +14,9 @@ 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,6 +33,9 @@ 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
+3
View File
@@ -7,6 +7,9 @@ on:
pull_request:
types: [opened, edited]
permissions:
contents: read
jobs:
superbot-orglabel:
runs-on: ubuntu-slim
+3
View File
@@ -7,6 +7,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
labeler:
permissions:
+3
View File
@@ -3,6 +3,9 @@ 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
+3
View File
@@ -19,6 +19,9 @@ 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
+3
View File
@@ -17,6 +17,9 @@ env:
GITHUB_ORG: ${{ github.repository_owner }}
GITHUB_REPO: ${{ github.event.repository.name }}
permissions:
contents: read
jobs:
cleanup-expired:
name: Clean up expired showtime environments
+3
View File
@@ -26,6 +26,9 @@ env:
GITHUB_REPO: ${{ github.event.repository.name }}
GITHUB_ACTOR: ${{ github.actor }}
permissions:
contents: read
jobs:
sync:
name: 🎪 Sync PR to desired state
+3
View File
@@ -26,6 +26,9 @@ 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,6 +27,9 @@ concurrency:
group: helm-release
cancel-in-progress: false
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-26.04
@@ -22,6 +22,9 @@ 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,6 +14,9 @@ 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
+3
View File
@@ -13,6 +13,9 @@ on:
required: true
type: string
permissions:
contents: read
jobs:
supersetbot:
runs-on: ubuntu-26.04
+3
View File
@@ -5,6 +5,9 @@ on:
pull_request_target:
types: [opened]
permissions:
contents: read
jobs:
welcome:
runs-on: ubuntu-slim
@@ -97,6 +97,54 @@ 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
+49 -5
View File
@@ -46,13 +46,43 @@ 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: 'Does Superset work with my database?',
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]?',
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.',
},
@@ -61,15 +91,30 @@ 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 a trash bin to recover deleted assets?',
question: 'Does Superset have an archive panel or trash bin from which a user can recover deleted assets?',
answer:
'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.',
'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.',
},
]}
/>
@@ -214,8 +259,7 @@ 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/not responding when started (my home directory is
NFS mounted)?**
## Why does `flask fab` or Superset freeze, hang, or not respond 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
+2 -2
View File
@@ -62,8 +62,8 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.47",
"antd": "^6.5.4",
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001807",
"baseline-browser-mapping": "^2.11.13",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.1.3",
"docusaurus-theme-openapi-docs": "^5.1.3",
"js-yaml": "^5.2.3",
+8 -8
View File
@@ -6504,10 +6504,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.9.19:
version "2.11.12"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz#42ac48770bf73d292f60ce8ba4dc5e7ebb242ec3"
integrity sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
version "2.11.13"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
batch@0.6.1:
version "0.6.1"
@@ -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.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==
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==
ccount@^2.0.0:
version "2.0.1"
+3 -3
View File
@@ -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.0",
"pandas-gbq>=0.35.1",
# 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.1",
"sqlalchemy-bigquery>=1.17.2",
"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.5,<4.0",
"fastmcp>=3.4.6,<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.
+4 -4
View File
@@ -239,9 +239,9 @@ et-xmlfile==2.0.0
# openpyxl
exceptiongroup==1.3.0
# via fastmcp-slim
fastmcp==3.4.5
fastmcp==3.4.7
# via apache-superset
fastmcp-slim==3.4.5
fastmcp-slim==3.4.7
# via fastmcp
filelock==3.20.3
# via
@@ -640,7 +640,7 @@ pandas==2.3.3
# db-dtypes
# pandas-gbq
# prophet
pandas-gbq==0.35.0
pandas-gbq==0.35.1
# 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.1
sqlalchemy-bigquery==1.17.2
# via apache-superset
sqlalchemy-continuum==1.7.0
# via
@@ -18,8 +18,9 @@
from __future__ import annotations
import enum
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from typing import Any
import isodate
import pyarrow as pa
@@ -90,6 +91,8 @@ class Dimension:
definition: str | None = None
description: str | None = None
grain: Grain | None = None
verbose_name: str | None = field(default=None, compare=False)
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
class AggregationType(str, enum.Enum):
@@ -121,6 +124,9 @@ class Metric:
definition: str
description: str | None = None
aggregation: AggregationType | None = None
verbose_name: str | None = field(default=None, compare=False)
d3format: str | None = field(default=None, compare=False)
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
@dataclass(frozen=True)
@@ -0,0 +1,97 @@
# 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 pyarrow as pa
from superset_core.semantic_layers.types import Dimension, Metric
def test_dimension_metadata_is_not_part_of_identity() -> None:
first = Dimension(
"sales.region",
"region",
pa.utf8(),
verbose_name="Region",
metadata={"display_name": "Region"},
)
second = Dimension(
"sales.region",
"region",
pa.utf8(),
verbose_name="Sales region",
metadata={"display_name": "Sales region"},
)
assert first == second
assert {first, second} == {first}
def test_metric_metadata_is_not_part_of_identity() -> None:
first = Metric(
"sales.total_revenue",
"total_revenue",
pa.float64(),
"SUM(revenue)",
verbose_name="Total revenue",
d3format="$,.2f",
metadata={"unit": {"kind": "currency", "code": "USD"}},
)
second = Metric(
"sales.total_revenue",
"total_revenue",
pa.float64(),
"SUM(revenue)",
verbose_name="Revenue",
d3format=",.0f",
metadata={"unit": {"kind": "currency", "code": "EUR"}},
)
assert first == second
assert {first, second} == {first}
def test_metric_accepts_superset_presentation_fields() -> None:
metric = Metric(
"sales.total_revenue",
"total_revenue",
pa.float64(),
"SUM(revenue)",
verbose_name="Total revenue",
d3format="$,.2f",
)
assert metric.verbose_name == "Total revenue"
assert metric.d3format == "$,.2f"
def test_dimension_accepts_superset_presentation_fields() -> None:
dimension = Dimension(
"sales.region",
"region",
pa.utf8(),
verbose_name="Region",
)
assert dimension.verbose_name == "Region"
def test_metadata_defaults_are_not_shared() -> None:
first = Metric("first", "first", pa.int64(), "COUNT(*)")
second = Metric("second", "second", pa.int64(), "COUNT(*)")
first.metadata["display_name"] = "First"
assert second.metadata == {}
+34 -37
View File
@@ -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.0",
"@testing-library/jest-dom": "^7.0.1",
"@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.1.2",
"@types/node": "^26.2.0",
"@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.12",
"baseline-browser-mapping": "^2.11.13",
"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.0",
"eslint": "^10.8.1",
"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.10",
"tsx": "^4.23.12",
"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.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
"integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
"integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
@@ -11851,7 +11851,13 @@
"yarn": ">=1"
},
"peerDependencies": {
"@testing-library/dom": ">=10 <11"
"@testing-library/dom": ">=10 <11",
"vitest": ">= 0.32"
},
"peerDependenciesMeta": {
"vitest": {
"optional": true
}
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
@@ -12702,9 +12708,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -16000,9 +16006,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
"integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==",
"version": "2.11.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
"integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -20140,9 +20146,9 @@
}
},
"node_modules/eslint": {
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -33246,9 +33252,9 @@
"license": "MIT"
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"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==",
"dev": true,
"funding": [
{
@@ -34955,9 +34961,9 @@
}
},
"node_modules/react-checkbox-tree/node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"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==",
"funding": [
{
"type": "github",
@@ -39966,9 +39972,9 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.10",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.10.tgz",
"integrity": "sha512-0Vb9eKU47njkxv/6B8CRZRDsxNDT/Pz+BIU+M5jw7xL3TdzAjSxlZUxu0xFL/kLpaG3sHZ0LH2wbK1T1yo7CUQ==",
"version": "4.23.12",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -43278,7 +43284,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.1.2",
"@types/node": "^26.2.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -43353,15 +43359,6 @@
"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",
+6 -5
View File
@@ -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.0",
"@testing-library/jest-dom": "^7.0.1",
"@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.1.2",
"@types/node": "^26.2.0",
"@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.12",
"baseline-browser-mapping": "^2.11.13",
"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.0",
"eslint": "^10.8.1",
"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.10",
"tsx": "^4.23.12",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
@@ -415,6 +415,7 @@
"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.1.2",
"@types/node": "^26.2.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -65,6 +65,7 @@ export type AntdExposedProps = Pick<
| 'onOpenChange'
| 'optionRender'
| 'placeholder'
| 'prefix'
| 'showArrow'
| 'showSearch'
| 'tokenSeparators'
@@ -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 } from './utils';
import { convertUTCTimestampToLocal, getFormattedUTCTime } 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,6 +103,8 @@ 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;
@@ -166,6 +168,7 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
itemName: '',
valueFormatter,
timeFormatter,
dateFormatter,
subDomainTextFormat,
});
});
@@ -76,6 +76,8 @@ var CalHeatMap = function () {
timeFormatter: d => d,
dateFormatter: null,
domain: 'hour',
subDomain: 'min',
@@ -1990,10 +1992,14 @@ 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,9 +25,11 @@ 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[];
@@ -38,6 +40,7 @@ let mockInitCallCount = 0;
let mockThrowOnInitCall: number | null = null;
let mockDestroyCallCount = 0;
let mockDestroyedInstanceIds: string[] = [];
let mockDateFormatter: MockCalHeatMapConfig['dateFormatter'];
const mockTheme = {
colorBgElevated: '#ffffff',
@@ -56,6 +59,7 @@ jest.mock('../src/vendor/cal-heatmap', () => ({
} = require('../src/tooltip');
mockInitCallCount += 1;
mockDateFormatter = config.dateFormatter;
if (mockThrowOnInitCall === mockInitCallCount) {
throw new Error('Mock CalHeatMap init failure');
}
@@ -284,9 +288,28 @@ 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();
@@ -0,0 +1,61 @@
/**
* 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');
});
@@ -16,9 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from '../../../../spec/helpers/testing-library';
import type { EChartsCoreOption } from 'echarts/core';
import Echart, { isReportScreenshotMode } from './Echart';
import { render, waitFor } from '../../../../spec/helpers/testing-library';
import Echart, {
ECHARTS_HOST_CLASS,
ECHARTS_RENDER_FINISHED_CLASS,
isReportScreenshotMode,
} from './Echart';
import type { EchartsProps } from '../types';
type Handler = (params: unknown) => void;
@@ -272,3 +276,31 @@ test('keeps animation enabled when not in report screenshot mode', async () => {
const lastOptions = mockChart.setOption.mock.calls.at(-1)?.[0];
expect(lastOptions.animation).not.toBe(false);
});
test('tags the ECharts canvas host with the readiness-gate class', async () => {
const { container } = render(renderEchart(), {
initialState,
useRedux: true,
});
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
expect(container.querySelector(`.${ECHARTS_HOST_CLASS}`)).not.toBeNull();
});
test('marks the host painted only on the ECharts `finished` event', async () => {
const { container } = render(renderEchart(), {
initialState,
useRedux: true,
});
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
const host = container.querySelector(`.${ECHARTS_HOST_CLASS}`) as HTMLElement;
expect(host).not.toBeNull();
// `setOption` ran during mount, which clears the marker; `finished` has not
// fired yet, so the host must NOT be flagged as painted.
expect(host).not.toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
// Simulate ECharts completing its draw -> the host is flagged painted.
trigger('finished');
expect(host).toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
});
@@ -138,6 +138,15 @@ export function isReportScreenshotMode(): boolean {
}
}
// Report-screenshot readiness contract (see superset/utils/screenshot_utils.py).
// `echarts-host` marks the canvas host element; `echarts-render-finished` is
// toggled OFF before each setOption and ON in the ECharts `finished` event --
// the only signal that the canvas is fully painted (chartStatus/onRenderSuccess
// both fire pre-paint). The readiness gate treats a host that lacks
// `echarts-render-finished` as not-yet-painted so it never captures a blank chart.
export const ECHARTS_HOST_CLASS = 'echarts-host';
export const ECHARTS_RENDER_FINISHED_CLASS = 'echarts-render-finished';
function Echart(
{
width,
@@ -201,6 +210,11 @@ function Echart(
width,
height,
});
// Paint marker for the report-screenshot readiness gate. `finished`
// is the only event that guarantees the canvas is fully drawn.
chartRef.current.on('finished', () => {
divRef.current?.classList.add(ECHARTS_RENDER_FINISHED_CLASS);
});
}
// did mount
handleSizeChange({ width, height });
@@ -321,6 +335,9 @@ function Echart(
}
)?.dataZoom
: undefined;
// Clear the paint marker before (re)drawing; the `finished` handler
// re-adds it once the new frame is fully rendered.
divRef.current?.classList.remove(ECHARTS_RENDER_FINISHED_CLASS);
chartRef.current?.setOption(themedEchartOptions, {
notMerge,
replaceMerge: notMerge ? undefined : ['series'],
@@ -412,7 +429,14 @@ function Echart(
handleSizeChange({ width, height });
}, [width, height, handleSizeChange]);
return <Styles ref={divRef} height={height} width={width} />;
return (
<Styles
ref={divRef}
className={ECHARTS_HOST_CLASS}
height={height}
width={width}
/>
);
}
export default forwardRef(Echart);
@@ -867,7 +867,7 @@ function DatasourceEditor({
return {
...metric,
certification_details: certificationDetails || details,
warning_markdown: warningMarkdown || '',
warning_markdown: warningMarkdown || metric.warning_markdown || '',
certified_by: certifiedBy || certifiedByMetric,
};
}),
@@ -0,0 +1,73 @@
/**
* 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',
}),
);
});
});
@@ -0,0 +1,97 @@
/**
* 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 } from 'react';
import { ReactNode, useCallback, useEffect, useRef } from 'react';
import { Divider, Form, Typography } from '@superset-ui/core/components';
import { css } from '@apache-superset/core/theme';
import { recurseReactClone } from '../../utils';
@@ -39,14 +39,24 @@ 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?.({
...item,
...itemRef.current,
[fieldKey]: val,
});
},
[onChange, item],
[onChange],
);
const propExtender = (field: { props: { fieldKey: fieldKeyType } }) => ({
@@ -135,6 +135,15 @@ describe('dashboardState actions', () => {
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('saveDashboardRequest', () => {
const findDangerToast = (dispatch: jest.Mock) =>
dispatch.mock.calls
.map(call => call[0])
.find(
action =>
action?.type === ADD_TOAST &&
action.payload.toastType === ToastType.Danger,
);
test('should dispatch UPDATE_COMPONENTS_PARENTS_LIST action', () => {
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: false },
@@ -227,6 +236,89 @@ describe('dashboardState actions', () => {
const { body } = putStub.mock.calls[0][0];
expect(body).toBe(JSON.stringify(confirmedDashboardData));
});
test('warns about the overwrite values when a diff is detected', async () => {
const { getState, dispatch } = setup();
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
thunk(dispatch, getState);
await waitFor(() =>
expect(findDangerToast(dispatch)?.payload.text).toBe(
'Please confirm the overwrite values.',
),
);
expect(putStub.mock.calls.length).toBe(0);
});
test('reports the actual error when the overwrite precheck fails', async () => {
getStub.mockRestore();
getStub = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('precheck exploded'));
const { getState, dispatch } = setup();
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
thunk(dispatch, getState);
await waitFor(() =>
expect(findDangerToast(dispatch)?.payload.text).toContain(
'precheck exploded',
),
);
expect(putStub.mock.calls.length).toBe(0);
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('when FeatureFlag.CONFIRM_DASHBOARD_DIFF is disabled', () => {
beforeEach(() => {
mockIsFeatureEnabled.mockImplementation(() => false);
});
afterEach(() => {
mockIsFeatureEnabled.mockRestore();
});
test('never runs the overwrite precheck', async () => {
const { getState, dispatch } = setup();
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
thunk(dispatch, getState);
await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
expect(getStub).not.toHaveBeenCalledWith(
expect.objectContaining({ endpoint: '/api/v1/dashboard/192' }),
);
});
// An unexpected failure used to reach the overwrite-confirm handler,
// which reported it as "Please confirm the overwrite values." even with
// the feature flag off, hiding the real error.
test('reports the actual error when the update throws unexpectedly', async () => {
putStub.mockRestore();
putStub = jest.spyOn(SupersetClient, 'put').mockImplementation(() => {
throw new Error('unexpected boom');
});
const { getState, dispatch } = setup();
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
thunk(dispatch, getState);
await waitFor(() =>
expect(findDangerToast(dispatch)?.payload.text).toContain(
'unexpected boom',
),
);
});
});
test('should navigate to the new dashboard after Save As', async () => {
@@ -379,15 +471,6 @@ describe('dashboardState actions', () => {
// permission-denied copy, while a 403 from outside Superset (reverse proxy,
// WAF, SSO gateway) carries a non-JSON body and must fall back to the
// generic status-derived toast. See #42239.
const findDangerToast = (dispatch: jest.Mock) =>
dispatch.mock.calls
.map(call => call[0])
.find(
action =>
action?.type === ADD_TOAST &&
action.payload.toastType === ToastType.Danger,
);
test('maps a non-JSON 403 save failure to the generic error toast', async () => {
const { getState, dispatch } = setup();
putStub.mockRestore();
@@ -646,6 +646,7 @@ export function saveDashboardRequest(
};
const onError = async (response: Response): Promise<void> => {
logging.error(response);
const { error, message } = await getClientErrorObject(response);
let errorText = t('Sorry, an unknown error occurred');
@@ -689,64 +690,64 @@ export function saveDashboardRequest(
}),
};
const updateDashboard = (): Promise<JsonObject | void> =>
SupersetClient.put({
endpoint: `/api/v1/dashboard/${id}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedDashboard),
})
.then(response => onUpdateSuccess(response))
.catch(response => onError(response));
return new Promise<void>((resolve, reject) => {
if (
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
) {
// skip overwrite precheck
resolve();
return;
const updateDashboard = async (): Promise<JsonObject | void> => {
try {
const response = await SupersetClient.put({
endpoint: `/api/v1/dashboard/${id}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedDashboard),
});
return await onUpdateSuccess(response);
} catch (error) {
return onError(error as Response);
}
};
// precheck for overwrite items
SupersetClient.get({
endpoint: `/api/v1/dashboard/${id}`,
}).then((response: JsonObject) => {
if (
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
) {
// skip overwrite precheck
return updateDashboard();
}
// precheck for overwrite items
return SupersetClient.get({
endpoint: `/api/v1/dashboard/${id}`,
})
.then((response: JsonObject) => {
const dashboard = (response.json as JsonObject).result as JsonObject;
const overwriteConfirmItems = getOverwriteItems(
dashboard,
updatedDashboard,
);
if (overwriteConfirmItems.length > 0) {
dispatch(
setOverrideConfirm({
updatedAt: dashboard.changed_on as string,
updatedBy: dashboard.changed_by_name as string,
overwriteConfirmItems:
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
| { overwriteConfirmItems: infer I }
| undefined
? I
: never,
dashboardId: id,
data: updatedDashboard,
}),
);
return reject(overwriteConfirmItems);
if (overwriteConfirmItems.length === 0) {
return updateDashboard();
}
return resolve();
});
})
.then(updateDashboard)
.catch((overwriteConfirmItems: JsonObject[]) => {
const errorText = t('Please confirm the overwrite values.');
dispatch(
setOverrideConfirm({
updatedAt: dashboard.changed_on as string,
updatedBy: dashboard.changed_by_name as string,
overwriteConfirmItems:
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
| { overwriteConfirmItems: infer I }
| undefined
? I
: never,
dashboardId: id,
data: updatedDashboard,
}),
);
dispatch(
logEvent(LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, {
dashboard_id: id,
items: overwriteConfirmItems,
}),
);
dispatch(addDangerToast(errorText));
});
dispatch(addDangerToast(t('Please confirm the overwrite values.')));
return undefined;
})
.catch(onError);
}
// changing the data as the endpoint requires
if (
@@ -27,7 +27,7 @@ import {
} from '@superset-ui/core';
import { Dispatch } from 'redux';
import { RootState } from 'src/dashboard/types';
import { cloneDeep } from 'lodash-es';
import { cloneDeep, omit } from 'lodash-es';
import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions';
import { HYDRATE_DASHBOARD } from './hydrate';
import {
@@ -90,12 +90,20 @@ 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: response.result,
filterChanges: savedFilters,
deletedIds: filterChanges.deleted,
});
dispatch(nativeFiltersConfigChanged(response.result));
dispatch(nativeFiltersConfigChanged(savedFilters));
dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters));
} catch (err) {
dispatch({
@@ -68,6 +68,7 @@ export const TableControls = ({
canDownload,
rowLimit,
rowLimitOptions,
effectiveRowLimit,
onRowLimitChange,
onDownloadCSV,
onDownloadXLSX,
@@ -111,14 +112,18 @@ export const TableControls = ({
value={rowLimit}
onChange={onRowLimitChange}
options={rowLimitOptions ?? []}
// Labelled as the applied limit to avoid a second row count next to RowCountLabel.
prefix={t('Limit')}
css={css`
min-width: 110px;
min-width: 160px;
`}
/>
)}
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
<RowCountLabel rowcount={rowcount} loading={isLoading} />
)}
<RowCountLabel
rowcount={rowcount}
limit={effectiveRowLimit ?? rowLimit}
loading={isLoading}
/>
{canDownload && onDownloadCSV && onDownloadXLSX && (
<DownloadDropdown
onDownloadCSV={onDownloadCSV}
@@ -56,6 +56,7 @@ export const SingleQueryResultPane = ({
columnDisplayNames,
rowLimit,
rowLimitOptions,
effectiveRowLimit,
onRowLimitChange,
onDownloadCSV,
onDownloadXLSX,
@@ -86,6 +87,7 @@ export const SingleQueryResultPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={rowLimitOptions}
effectiveRowLimit={effectiveRowLimit}
onRowLimitChange={onRowLimitChange}
onDownloadCSV={onDownloadCSV}
onDownloadXLSX={onDownloadXLSX}
@@ -236,6 +236,7 @@ export const useResultsPane = ({
columnDisplayNames={columnDisplayNames}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
effectiveRowLimit={effectiveRowLimit}
onRowLimitChange={handleRowLimitChange}
/>
</StyledDiv>
@@ -0,0 +1,83 @@
/**
* 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 { render, screen, userEvent } from 'spec/helpers/testing-library';
import { GenericDataType } from '@apache-superset/core/common';
import {
TableControls,
ROW_LIMIT_OPTIONS,
} from '../components/DataTableControls';
import { TableControlsProps } from '../types';
const setup = (overrides: Partial<TableControlsProps> = {}) =>
render(
<TableControls
data={[]}
columnNames={['name']}
columnTypes={[GenericDataType.String]}
rowcount={0}
onInputChange={jest.fn()}
isLoading={false}
canDownload
rowLimit={100}
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={jest.fn()}
{...overrides}
/>,
{ useRedux: true },
);
test('shows the row count when the result fills the selected row limit', () => {
setup({ rowcount: 100, rowLimit: 100 });
expect(screen.getByTestId('row-count-label')).toHaveTextContent('100 rows');
});
test('warns that the row limit was reached when the result fills it', async () => {
setup({ rowcount: 100, rowLimit: 100 });
userEvent.hover(screen.getByTestId('row-count-label'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test('does not warn when the result is smaller than the selected row limit', () => {
setup({ rowcount: 42, rowLimit: 100 });
expect(screen.getByTestId('row-count-label')).toHaveTextContent('42 rows');
userEvent.hover(screen.getByTestId('row-count-label'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
test("warns when the chart's own row limit truncates below the selected row limit", async () => {
setup({ rowcount: 250, rowLimit: 1000, effectiveRowLimit: 250 });
userEvent.hover(screen.getByTestId('row-count-label'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test('labels the row limit selector so it is not read as a second row count', () => {
setup({ rowcount: 100, rowLimit: 100 });
expect(screen.getByText('Limit')).toBeInTheDocument();
});
@@ -84,6 +84,9 @@ export interface TableControlsProps extends DrillControlsProps {
canDownload: boolean;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
// Effective result limit, capped by the chart's row limit.
// Defaults to `rowLimit` and controls the "row limit reached" warning.
effectiveRowLimit?: number;
onRowLimitChange?: (limit: number) => void;
}
@@ -104,5 +107,6 @@ export interface SingleQueryResultPaneProp
columnDisplayNames?: Record<string, string>;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
effectiveRowLimit?: number;
onRowLimitChange?: (limit: number) => void;
}
+12 -12
View File
@@ -15,18 +15,18 @@
"jsonwebtoken": "^9.0.3",
"lodash-es": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.21.2"
"ws": "^8.21.3"
},
"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.1.2",
"@types/node": "^26.2.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^10.8.0",
"eslint": "^10.8.1",
"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.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1689,9 +1689,9 @@
}
},
"node_modules/eslint": {
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -3520,9 +3520,9 @@
}
},
"node_modules/ws": {
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+3 -3
View File
@@ -23,18 +23,18 @@
"jsonwebtoken": "^9.0.3",
"lodash-es": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.21.2"
"ws": "^8.21.3"
},
"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.1.2",
"@types/node": "^26.2.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^10.8.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.9.0",
"oxfmt": "^0.62.0",
+72 -6
View File
@@ -137,6 +137,29 @@ def resolve_executor_user(model: ReportSchedule) -> tuple["User", str]:
return user, username
def _should_build_execution_context(model: ReportSchedule) -> bool:
"""
Whether an execution should run under a :class:`ReportExecutionContext`.
Reports always do their behavior is unchanged. Alerts join them only when
they deliver a rendered PNG/PDF screenshot to recipients, which happens when
``ALERTS_ATTACH_REPORTS`` is enabled. Delivered screenshots must fail closed:
the context selects the fail-closed readiness predicate and disables
partial-tile fallback, so a blank or incomplete capture raises instead of
being delivered.
CSV/text alerts, alerts without the attach flag, the non-delivered
query-context capture, and UI thumbnails are deliberately excluded and keep
their lenient capture contract.
"""
if model.type == ReportScheduleType.REPORT:
return True
return model.report_format in (
ReportDataFormat.PNG,
ReportDataFormat.PDF,
) and feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
def log_report_delivery_phase(
report_context: ReportExecutionContext | None,
recipient_type: ReportRecipientType | None,
@@ -1972,13 +1995,13 @@ class ReportSuccessState(BaseReportState):
try:
self.send()
except Exception as ex: # pylint: disable=broad-except
if self._handle_retry_or_error(str(ex), ex):
except Exception as first_ex: # pylint: disable=broad-except
if self._handle_retry_or_error(str(first_ex), first_ex):
return # retry scheduled — exit cleanly
try:
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=str(ex)
ReportState.ERROR, error_message=str(first_ex)
)
except (ReportScheduleUnexpectedError, SQLAlchemyError) as logging_ex:
# Logging failed (likely StaleDataError), but we still want to
@@ -1991,7 +2014,45 @@ class ReportSuccessState(BaseReportState):
exc_info=True,
)
# Re-raise the original exception, not the logging failure
raise ex from logging_ex
raise first_ex from logging_ex
# A delivery failure from the Success/Grace path must notify the
# owner just like the first-run path (ReportNotTriggeredErrorState).
# Without this, a schedule whose previous run succeeded would fail
# silently — e.g. once a screenshot capture starts failing closed.
# The error grace period still throttles repeated notifications.
if not self.is_in_error_grace_period():
second_error_message = REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
try:
self.send_error(
f"Error occurred for {self._report_schedule.type}:"
f" {self._report_schedule.name}",
str(first_ex),
)
except SupersetErrorsException as second_ex:
second_error_message = ";".join(
[error.message for error in second_ex.errors]
)
except ReportScheduleUnexpectedError:
# send_error failed due to logging issue; log and continue
# to raise the original error
logger.warning(
"Failed to send error notification due to database issue",
exc_info=True,
)
except Exception as second_ex: # pylint: disable=broad-except
second_error_message = str(second_ex)
finally:
try:
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=second_error_message
)
except ReportScheduleUnexpectedError:
# Logging failed again; log it but don't hide first_ex
logger.warning(
"Failed to log final error state due to database issue",
exc_info=True,
)
raise
# send() succeeded — clear retry state and log success. Any execution
@@ -2058,13 +2119,18 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
if not self._model:
raise ReportScheduleExecuteUnexpectedError()
if self._model.type == ReportScheduleType.REPORT:
# Reports always run under an execution context; alerts join them
# only when they deliver a rendered screenshot, so a blank/partial
# capture fails closed instead of being delivered. Ownership and
# terminal-error persistence remain report-only recovery semantics.
if _should_build_execution_context(self._model):
# An invocation that enters on WORKING is a duplicate or stale
# recovery, not the owner that created the active row. Its state
# handler may terminalize a stale execution, but the command
# boundary must never infer ownership from a replayed UUID.
owns_report_working_state = (
self._model.last_state != ReportState.WORKING
self._model.type == ReportScheduleType.REPORT
and self._model.last_state != ReportState.WORKING
)
total_seconds = resolve_report_execution_budget_seconds(
app.config,
@@ -21,6 +21,7 @@ from __future__ import annotations
from typing import Any
from flask_babel import gettext as __
from jinja2.exceptions import TemplateError
from superset import db
from superset.commands.streaming_export.base import BaseStreamingCSVExportCommand
@@ -86,6 +87,15 @@ class StreamingSqlResultExportCommand(BaseStreamingCSVExportCommand):
),
status=403,
) from ex
except TemplateError as ex:
raise SupersetErrorException(
SupersetError(
message=str(ex),
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
level=ErrorLevel.ERROR,
),
status=400,
) from ex
def _get_sql_and_database(self) -> tuple[str, Any, str | None, str | None]:
"""
+11 -1
View File
@@ -34,6 +34,7 @@ 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
@@ -547,7 +548,9 @@ class DashboardDAO(BaseDAO[Dashboard]):
cls, id: str
) -> dict[str, list[dict[str, Any]]]:
dashboard = cls.get_by_id_or_slug(id)
metadata = json.loads(dashboard.json_metadata or "{}")
metadata = derive_metadata_scopes(
dashboard, json.loads(dashboard.json_metadata or "{}")
)
native_filter_configuration = metadata.get("native_filter_configuration", [])
tab_filters = defaultdict(list)
@@ -617,6 +620,13 @@ 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
+7
View File
@@ -91,6 +91,7 @@ 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,
@@ -653,6 +654,12 @@ 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
+275
View File
@@ -0,0 +1,275 @@
# 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))
+21 -2
View File
@@ -165,12 +165,31 @@ 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:
backend = dialect.name
# 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
if isinstance(backend, bytes):
backend = backend.decode()
backend = backend_replacements.get(backend, backend)
driver = getattr(dialect, "driver", dialect.name)
driver = dialect.driver
if isinstance(driver, bytes):
driver = driver.decode()
drivers[backend].add(driver)
+25 -9
View File
@@ -324,7 +324,9 @@ class SemanticView(AuditMixinNullable, Model):
MetricMetadata(
metric_name=metric.name,
expression=metric.definition,
verbose_name=metric.verbose_name,
description=metric.description,
d3format=metric.d3format,
)
for metric in self.implementation.get_metrics()
]
@@ -357,6 +359,7 @@ class SemanticView(AuditMixinNullable, Model):
is_dttm=pa.types.is_date(dimension.type)
or pa.types.is_time(dimension.type)
or pa.types.is_timestamp(dimension.type),
verbose_name=dimension.verbose_name,
description=dimension.description,
expression=None,
extra=json.dumps(
@@ -372,6 +375,19 @@ class SemanticView(AuditMixinNullable, Model):
@property
def data(self) -> ExplorableData:
dimensions = self._unique_dimensions
metrics = list(self.implementation.get_metrics())
verbose_map = {
**{metric.name: metric.verbose_name or metric.name for metric in metrics},
**{
dimension.name: dimension.verbose_name or dimension.name
for dimension in dimensions
},
}
column_formats = {
metric.name: metric.d3format for metric in metrics if metric.d3format
}
return {
# core
"id": self.id,
@@ -399,16 +415,16 @@ class SemanticView(AuditMixinNullable, Model):
"python_date_format": None,
"type": str(dimension.type),
"type_generic": get_column_type(dimension.type),
"verbose_name": None,
"verbose_name": dimension.verbose_name,
"warning_markdown": None,
}
for dimension in self._unique_dimensions
for dimension in dimensions
],
"metrics": [
{
"certification_details": None,
"certified_by": None,
"d3format": None,
"d3format": metric.d3format,
"description": metric.description,
"expression": metric.definition,
"id": None,
@@ -417,14 +433,14 @@ class SemanticView(AuditMixinNullable, Model):
"metric_name": metric.name,
"warning_markdown": None,
"warning_text": None,
"verbose_name": None,
"verbose_name": metric.verbose_name,
}
for metric in self.implementation.get_metrics()
for metric in metrics
],
"database": {},
"parent": {"name": self.semantic_layer.name},
# UI features
"verbose_map": {},
"verbose_map": verbose_map,
"order_by_choices": [],
"filter_select": True,
"filter_select_enabled": True,
@@ -436,11 +452,11 @@ class SemanticView(AuditMixinNullable, Model):
"description": self.description,
"table_name": self.name,
"column_types": [
get_column_type(dimension.type) for dimension in self._unique_dimensions
get_column_type(dimension.type) for dimension in dimensions
],
"column_names": [dimension.name for dimension in self._unique_dimensions],
"column_names": [dimension.name for dimension in dimensions],
# rare
"column_formats": {},
"column_formats": column_formats,
"datasource_name": self.name,
"perm": self.perm,
"offset": self.offset,
+40 -3
View File
@@ -144,6 +144,21 @@ TERMINAL_MARKER_SELECTOR = (
)
CHART_ID_CLASS_PATTERN = r"\bdashboard-chart-id-(\d+)\b"
# ECharts paint marker. The frontend
# (plugins/plugin-chart-echarts/src/components/Echart.tsx) tags the canvas host
# ``.echarts-host`` and adds ``.echarts-render-finished`` only in the ECharts
# ``finished`` event -- the sole signal that the canvas is fully painted.
# ``.slice_container`` alone is a pre-paint signal (it mounts when data arrives,
# before the canvas is drawn; chartStatus/onRenderSuccess fire pre-paint too), so
# a holder that still contains an unpainted host is treated as not-yet-rendered and
# the report screenshot waits for it instead of capturing a blank chart. Only
# ECharts hosts are gated; DOM/SVG vizzes paint on commit and non-ECharts canvas
# vizzes (deck.gl/mapbox/etc.) have no ``.echarts-host`` so they are unaffected.
ECHARTS_UNPAINTED_HOST_SELECTOR = r".echarts-host:not(.echarts-render-finished)"
CHART_ERROR_OR_EMPTY_SELECTOR = (
f"{ALERT_SELECTOR}, {EMPTY_SELECTOR}, {MISSING_CHART_SELECTOR}"
)
# Shared body for holder readiness and timeout diagnostics. A holder is ready
# only after a terminal marker appears and its loading marker disappears.
UNREADY_CHART_HOLDERS_JS_BODY = f"""
@@ -158,8 +173,19 @@ UNREADY_CHART_HOLDERS_JS_BODY = f"""
'{SLICE_CONTAINER_SELECTOR}'
) !== null;
const stillLoading = holder.querySelector('{LOADING_SELECTOR}') !== null;
const isReady = holder.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null;
if (stillLoading || !isReady) {{
const hasErrorOrEmpty = holder.querySelector(
'{CHART_ERROR_OR_EMPTY_SELECTOR}'
) !== null;
const hasUnpaintedEchart = holder.querySelector(
'{ECHARTS_UNPAINTED_HOST_SELECTOR}'
) !== null;
// Ready = a settled error/empty/missing state, or a slice container
// whose ECharts canvas has finished painting. An unpainted ECharts host
// keeps the holder unready so a blank chart is never captured.
const isReady = !stillLoading && (
hasErrorOrEmpty || (hasSliceContainer && !hasUnpaintedEchart)
);
if (!isReady) {{
const chartIdMatch = holder.className.match(/{CHART_ID_CLASS_PATTERN}/);
const chartId = chartIdMatch ? chartIdMatch[1] : null;
let state;
@@ -167,6 +193,8 @@ UNREADY_CHART_HOLDERS_JS_BODY = f"""
state = 'spinner_mounted';
}} else if (stillLoading) {{
state = 'waiting_on_database';
}} else if (hasSliceContainer && hasUnpaintedEchart) {{
state = 'mounted_unpainted';
}} else {{
state = 'nothing_mounted';
}}
@@ -208,6 +236,11 @@ FIND_CHART_HOLDER_STATES_JS = f"""
) !== null) {{
return {{ chartId, state: 'empty' }};
}}
if (hasSliceContainer && holder.querySelector(
'{ECHARTS_UNPAINTED_HOST_SELECTOR}'
) !== null) {{
return {{ chartId, state: 'mounted_unpainted' }};
}}
if (hasSliceContainer) {{
return {{ chartId, state: 'rendered' }};
}}
@@ -237,7 +270,8 @@ CHART_CONTAINER_READY_JS = f"""
const chart = document.querySelector('.chart-container');
return chart !== null
&& chart.querySelector('{LOADING_SELECTOR}') === null
&& chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null;
&& chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null
&& chart.querySelector('{ECHARTS_UNPAINTED_HOST_SELECTOR}') === null;
}}
"""
@@ -249,6 +283,9 @@ CHART_CONTAINER_STATE_JS = f"""
const chart = document.querySelector('.chart-container');
if (chart === null) {{ return 'missing'; }}
if (chart.querySelector('{LOADING_SELECTOR}') !== null) {{ return 'loading'; }}
if (chart.querySelector('{ECHARTS_UNPAINTED_HOST_SELECTOR}') !== null) {{
return 'mounted_unpainted';
}}
if (chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null) {{
return 'terminal';
}}
@@ -646,6 +646,65 @@ 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
@@ -46,6 +46,7 @@ from superset.commands.report.exceptions import (
ReportScheduleXlsxFailedError,
)
from superset.commands.report.execute import (
_should_build_execution_context,
BaseReportState,
log_report_delivery_phase,
persist_owned_report_execution_terminal_error,
@@ -3747,6 +3748,8 @@ def test_success_state_send_error_logs_and_reraises(
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
)
mocker.patch.object(state, "send", side_effect=RuntimeError("send boom"))
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
mocker.patch.object(state, "send_error")
mocker.patch.object(state, "update_report_schedule_and_log")
with pytest.raises(RuntimeError, match="send boom"):
@@ -3808,6 +3811,46 @@ def test_get_notification_content_alert_no_flag_skips_attachment(
assert content.text is None
@pytest.mark.parametrize(
("schedule_type", "report_format", "attach_flag", "expected"),
[
# Reports always run under an execution context, regardless of format.
(ReportScheduleType.REPORT, ReportDataFormat.PNG, False, True),
(ReportScheduleType.REPORT, ReportDataFormat.PDF, False, True),
(ReportScheduleType.REPORT, ReportDataFormat.CSV, False, True),
(ReportScheduleType.REPORT, ReportDataFormat.TEXT, False, True),
# Alerts that deliver a rendered screenshot fail closed only when the
# ALERTS_ATTACH_REPORTS flag is on (otherwise no artifact is attached).
(ReportScheduleType.ALERT, ReportDataFormat.PNG, True, True),
(ReportScheduleType.ALERT, ReportDataFormat.PDF, True, True),
(ReportScheduleType.ALERT, ReportDataFormat.PNG, False, False),
(ReportScheduleType.ALERT, ReportDataFormat.PDF, False, False),
# CSV/text/xlsx alerts never deliver a rendered screenshot; they stay
# lenient even with the attach flag on.
(ReportScheduleType.ALERT, ReportDataFormat.CSV, True, False),
(ReportScheduleType.ALERT, ReportDataFormat.TEXT, True, False),
(ReportScheduleType.ALERT, ReportDataFormat.XLSX, True, False),
],
)
@patch("superset.commands.report.execute.feature_flag_manager")
def test_should_build_execution_context(
mock_ff: MagicMock,
mocker: MockerFixture,
schedule_type: ReportScheduleType,
report_format: ReportDataFormat,
attach_flag: bool,
expected: bool,
) -> None:
"""Only reports and rendered-screenshot alerts run fail closed under a
ReportExecutionContext; CSV/text alerts and flag-off alerts stay lenient."""
mock_ff.is_feature_enabled.return_value = attach_flag
model = mocker.Mock(spec=ReportSchedule)
model.type = schedule_type
model.report_format = report_format
assert _should_build_execution_context(model) is expected
def test_create_log_success_commits(mocker: MockerFixture) -> None:
"""Successful create_log creates a log entry and commits."""
schedule = mocker.Mock(spec=ReportSchedule)
@@ -4217,6 +4260,139 @@ def test_success_state_error_logged_when_send_error_raises(
assert ReportState.ERROR in states
@pytest.mark.parametrize(
"schedule_type",
[ReportScheduleType.REPORT, ReportScheduleType.ALERT],
)
def test_success_state_send_failure_notifies_owner(
mocker: MockerFixture,
schedule_type: ReportScheduleType,
) -> None:
"""A delivery failure from the Success/Grace path must notify the owner,
mirroring the first-run (ReportNotTriggeredErrorState) path otherwise a
previously-successful schedule fails silently (e.g. once a screenshot
capture starts failing closed)."""
state = _make_state_instance(
mocker, ReportSuccessState, schedule_type=schedule_type
)
# No retries configured (the default), so _handle_retry_or_error returns
# False immediately without sending anything.
mocker.patch.object(state, "is_in_grace_period", return_value=False)
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
mock_send_error = mocker.patch.object(state, "send_error")
if schedule_type == ReportScheduleType.ALERT:
mocker.patch(
"superset.commands.report.execute.AlertCommand"
).return_value.run.return_value = (True, "triggered")
mocker.patch.object(
state,
"send",
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
)
with pytest.raises(ReportScheduleScreenshotFailedError, match="blank screenshot"):
state.next()
mock_send_error.assert_called_once()
# The owner-notification path must also persist a terminal ERROR state,
# not leave the schedule stuck in WORKING (mirrors how the grace-period
# sibling test asserts the recorded terminal state).
assert mock_update.call_args_list[-1].args[0] == ReportState.ERROR
def test_success_state_send_failure_skips_notification_in_error_grace(
mocker: MockerFixture,
) -> None:
"""When inside the error grace period, the Success/Grace path logs ERROR
but suppresses the (throttled) error notification."""
state = _make_state_instance(
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
)
mocker.patch.object(state, "is_in_error_grace_period", return_value=True)
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
mock_send_error = mocker.patch.object(state, "send_error")
mocker.patch.object(
state,
"send",
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
)
with pytest.raises(ReportScheduleScreenshotFailedError):
state.next()
mock_send_error.assert_not_called()
states = [call.args[0] for call in mock_update.call_args_list]
assert ReportState.ERROR in states
@pytest.mark.parametrize(
("failure_kind", "expected_message"),
[
("superset_errors", "smtp down;retry failed"),
("generic", "smtp down"),
],
)
def test_success_state_send_error_failure_overwrites_marker(
mocker: MockerFixture,
failure_kind: str,
expected_message: str,
) -> None:
"""When the Success/Grace path's own error notification fails, the
placeholder marker is overwritten with the real failure message before
ERROR is logged -- mirroring the first-run (ReportNotTriggeredErrorState)
path. A SupersetErrorsException contributes its joined error messages; any
other exception contributes its ``str()``."""
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetErrorsException
if failure_kind == "superset_errors":
send_error_exc: Exception = SupersetErrorsException(
[
SupersetError(
message="smtp down",
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
level=ErrorLevel.ERROR,
),
SupersetError(
message="retry failed",
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
level=ErrorLevel.ERROR,
),
]
)
else:
send_error_exc = RuntimeError("smtp down")
state = _make_state_instance(
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
)
mocker.patch.object(state, "is_in_error_grace_period", return_value=False)
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
mock_send_error = mocker.patch.object(
state, "send_error", side_effect=send_error_exc
)
mocker.patch.object(
state,
"send",
side_effect=ReportScheduleScreenshotFailedError("blank screenshot"),
)
with pytest.raises(ReportScheduleScreenshotFailedError, match="blank screenshot"):
state.next()
mock_send_error.assert_called_once()
# The placeholder marker must be replaced by the real notification failure
# before the terminal ERROR row is written.
final_call = mock_update.call_args_list[-1]
assert final_call.args[0] == ReportState.ERROR
assert final_call.kwargs.get("error_message") == expected_message
assert (
final_call.kwargs.get("error_message")
!= REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
)
def test_get_url_for_csv_uses_post_processed_type(
app: SupersetApp,
mocker: MockerFixture,
@@ -21,6 +21,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from flask import g
from jinja2.exceptions import TemplateSyntaxError
from pytest_mock import MockerFixture
from superset.commands.sql_lab.streaming_export_command import (
@@ -133,6 +134,24 @@ def test_validate_access_denied(mock_db, mock_query):
assert exc_info.value.status == 403
@patch("superset.commands.sql_lab.streaming_export_command.db")
def test_validate_jinja_template_error(mock_db, mock_query):
"""Test validate converts a Jinja TemplateError into a 400 error."""
mock_query_result = mock_db.session.query.return_value.filter_by.return_value
mock_query_result.one_or_none.return_value = mock_query
mock_query.raise_for_access.side_effect = TemplateSyntaxError(
"unexpected end of template", lineno=1
)
command = StreamingSqlResultExportCommand("test_client_123")
with pytest.raises(SupersetErrorException) as exc_info:
command.validate()
assert exc_info.value.error.error_type == SupersetErrorType.GENERIC_COMMAND_ERROR
assert exc_info.value.status == 400
@patch("superset.commands.sql_lab.streaming_export_command.db")
def test_validate_success(mock_db, mock_query):
"""Test successful validation."""
@@ -0,0 +1,233 @@
# 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,6 +18,7 @@
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.engine.default import DefaultDialect
from superset.db_engine_specs import get_available_engine_specs
@@ -50,6 +51,92 @@ 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"}}}],
@@ -272,6 +272,7 @@ def mock_dimensions() -> list[Dimension]:
definition="orders.order_date",
description="Date of the order",
grain=Grains.DAY,
verbose_name="Order date",
),
Dimension(
id="products.category",
@@ -280,6 +281,7 @@ def mock_dimensions() -> list[Dimension]:
definition="products.category",
description="Product category",
grain=None,
verbose_name="Category",
),
]
@@ -294,6 +296,8 @@ def mock_metrics() -> list[Metric]:
type=pa.float64(),
definition="SUM(orders.amount)",
description="Total revenue",
verbose_name="Total revenue",
d3format="$,.2f",
),
Metric(
id="orders.count",
@@ -301,6 +305,8 @@ def mock_metrics() -> list[Metric]:
type=pa.int64(),
definition="COUNT(*)",
description="Number of orders",
verbose_name="Order count",
d3format=",.0f",
),
]
@@ -481,7 +487,9 @@ def test_semantic_view_metrics(
assert len(metrics) == 2
assert metrics[0].metric_name == "revenue"
assert metrics[0].expression == "SUM(orders.amount)"
assert metrics[0].verbose_name == "Total revenue"
assert metrics[0].description == "Total revenue"
assert metrics[0].d3format == "$,.2f"
assert metrics[1].metric_name == "order_count"
@@ -502,10 +510,12 @@ def test_semantic_view_columns(
assert columns[0].column_name == "order_date"
assert columns[0].type == "date32[day]"
assert columns[0].is_dttm is True
assert columns[0].verbose_name == "Order date"
assert columns[0].description == "Date of the order"
assert columns[1].column_name == "category"
assert columns[1].type == "string"
assert columns[1].is_dttm is False
assert columns[1].verbose_name == "Category"
def test_semantic_view_column_names(
@@ -632,15 +642,32 @@ def test_semantic_view_data(
assert data["columns"][0]["type"] == "date32[day]"
assert data["columns"][0]["is_dttm"] is True
assert data["columns"][0]["type_generic"] == GenericDataType.TEMPORAL
assert data["columns"][0]["verbose_name"] == "Order date"
assert data["columns"][1]["column_name"] == "category"
assert data["columns"][1]["type"] == "string"
assert data["columns"][1]["type_generic"] == GenericDataType.STRING
assert data["columns"][1]["verbose_name"] == "Category"
# Check metrics
assert len(data["metrics"]) == 2
assert data["metrics"][0]["metric_name"] == "revenue"
assert data["metrics"][0]["expression"] == "SUM(orders.amount)"
assert data["metrics"][0]["verbose_name"] == "Total revenue"
assert data["metrics"][0]["d3format"] == "$,.2f"
assert data["metrics"][1]["metric_name"] == "order_count"
assert data["metrics"][1]["verbose_name"] == "Order count"
assert data["metrics"][1]["d3format"] == ",.0f"
assert data["verbose_map"] == {
"revenue": "Total revenue",
"order_count": "Order count",
"order_date": "Order date",
"category": "Category",
}
assert data["column_formats"] == {
"revenue": "$,.2f",
"order_count": ",.0f",
}
# Check column_types and column_names
assert data["column_types"] == [
@@ -1148,3 +1148,21 @@ class TestTileWaitBudget:
assert args[1] == i + 1 # tile index
assert args[2] == 3 # total tiles
assert args[-1] == " [cache_key=xyz]"
def test_readiness_predicates_gate_on_unpainted_echarts_hosts() -> None:
"""The report gate, the single-chart gate, and the diagnostics query all
key on the ECharts paint marker so a pre-paint canvas is never captured."""
from superset.utils.screenshot_utils import (
CHART_CONTAINER_READY_JS,
ECHARTS_UNPAINTED_HOST_SELECTOR,
FIND_CHART_HOLDER_STATES_JS,
REPORT_CHART_HOLDERS_READY_JS,
)
assert (
ECHARTS_UNPAINTED_HOST_SELECTOR == ".echarts-host:not(.echarts-render-finished)"
)
assert ECHARTS_UNPAINTED_HOST_SELECTOR in REPORT_CHART_HOLDERS_READY_JS
assert ECHARTS_UNPAINTED_HOST_SELECTOR in CHART_CONTAINER_READY_JS
assert "mounted_unpainted" in FIND_CHART_HOLDER_STATES_JS