Compare commits

...

86 Commits

Author SHA1 Message Date
hainenber
5fb0e7eca8 chore(ci): reduce open PR limit as a singular value being applicable for all listed directories
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-10-04 10:03:19 +07:00
hainenber
be0bb512b9 feat(ci): group all npm Dependabot configs
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-30 20:35:20 +07:00
hainenber
d678d6b8ad fix(build): correct mainline branch name used by Superset
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-29 21:43:00 +07:00
hainenber
76bb82c293 fix(build): add hack to allow multiple configs for same ecosystem/directory combination
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-29 09:59:27 +07:00
hainenber
bb1a8e28e1 feat(ci): group Dependabot updates across directories in Superset monorepo
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-28 21:52:04 +07:00
hainenber
71aa3de941 feat(build): group jest-related PR for version update
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-28 21:18:29 +07:00
hainenber
7ceb7ca64c chore(ci): configure more grouped dep upgrades across npm subprojects
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-26 14:35:54 +07:00
hainenber
5c98700559 fix(ci): group github/codeql-action Dependabot PRs together to avoid version mismatching issue
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-26 13:08:40 +07:00
Joe Li
0501796837 chore(ci): collapse Playwright experimental workflow into E2E playwright-tests (#44659)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 10:48:37 +07:00
Amin Ghadersohi
181422fc60 fix(mcp): return safe argument validation details (#44591) 2026-09-25 19:25:39 -04:00
Elizabeth Thompson
10ca640e97 fix(import): downgrade expected validation-failure logs from error to warning (#44655)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:04:23 -07:00
Elizabeth Thompson
5aec4342bb fix(database): catch JSONDecodeError in get_oauth2_config (#44658)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 15:04:18 -07:00
Joe Li
4bce27f12f fix(dashboard): shrink row height on chart resize and unblock resize handles above columns (#44359)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-09-25 10:51:05 -07:00
dependabot[bot]
be0631858c chore(deps): bump mapbox-gl from 3.30.0 to 3.31.0 in /superset-frontend (#44643)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 10:23:14 -07:00
dependabot[bot]
241d121015 chore(deps): bump image-size from 2.0.2 to 2.0.4 in /docs in the security group across 1 directory (#44619)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 10:13:58 -07:00
dependabot[bot]
5e8bbc0eb4 chore(deps): bump baseline-browser-mapping from 2.11.24 to 2.11.25 in /docs (#44638)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 23:53:48 +07:00
dependabot[bot]
0034601c06 chore(deps): bump github/codeql-action/init from 4.38.0 to 4.38.1 (#44639)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 23:53:05 +07:00
dependabot[bot]
5be35992c5 chore(deps): bump github/codeql-action/upload-sarif from 4.38.0 to 4.38.1 (#44640)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 21:45:03 +07:00
dependabot[bot]
5085570fb4 chore(deps): bump apache/infrastructure-actions/allowlist-check from 1.0.3 to 1.0.4 (#44637)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 21:44:42 +07:00
dependabot[bot]
a44ab4d928 chore(deps-dev): bump baseline-browser-mapping from 2.11.24 to 2.11.25 in /superset-frontend (#44641)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 21:39:06 +07:00
dependabot[bot]
d1f903c66f chore(deps): bump github/codeql-action/analyze from 4.38.0 to 4.38.1 (#44636)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 21:38:31 +07:00
dependabot[bot]
d65e9fba9c chore(deps-dev): bump oxlint-tsgolint from 7.0.2001 to 7.0.2002 in /docs (#44635)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 21:38:09 +07:00
dependabot[bot]
a9342fa02a chore(deps-dev): bump oxlint-tsgolint from 7.0.2001 to 7.0.2002 in /superset-websocket (#44634)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 21:28:34 +07:00
Enzo Martellucci
83731ce7b3 test(reports): freeze the clock in the stale retry boundary test (#44651)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:40:12 +02:00
Mehmet Salih Yavuz
02cb1f1a28 fix(table): resolve auto-detected currency on time comparison columns (#44649) 2026-09-25 15:29:52 +03:00
Amin Ghadersohi
1e08d61204 fix(mcp): preserve deduplicated chart tool input schemas (#44575)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-25 21:44:43 +10:00
Enzo Martellucci
3bd6e103c1 fix(security): apply global guest RLS rules inside adhoc sub-queries (#44645)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:14:55 +02:00
anamitraadhikari
599026a0e5 feat(standalone): granular Explore standalone modes and iframe-safe dashboard fullscreen (#44165)
Co-authored-by: aadhikari <aadhikari@apple.com>
2026-09-24 16:29:56 -07:00
Evan Rusackas
96d31ee3e3 docs(mcp): document deleted_state trash listing for list_charts/list_dashboards (#44128)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:37:23 -04:00
Mike Bridge
7b716b1838 fix(versioning): clean up history actions and refresh after saves (#44613) 2026-09-24 18:36:38 -04:00
Elizabeth Thompson
855ca2228c fix(query_context): raise 422 not 500 on malformed Jinja in Query datasource access check (#44011)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-24 15:02:26 -07:00
Elizabeth Thompson
f5487f04a3 fix(db_engine_specs): raise SupersetGenericDBErrorException instead of bare-reraising JSONDecodeError (#44616)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:02:17 -07:00
akashchamp
defcd63910 fix(mongodb): route dataset FROM clauses through quote_table so chart queries return rows (#44583)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 14:16:12 -07:00
Devansh Bhargava
1c7d1f049b fix(utils): return None from cast_to_num for non-decimal digit strings (#44615) 2026-09-24 13:35:43 -07:00
Joe Li
e04d38a85f fix(mypy): resolve the dataset-scope and datasource-perm errors from the full-repo lint (#44610)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 13:30:54 -07:00
Deepak Jain (DJ)
e357014559 fix(dashboard): initialize label colors before mounting charts (#44557)
Signed-off-by: Deepak Jain <deepujain@gmail.com>
2026-09-24 13:30:03 -07:00
Konstantin Khlopkov
f5d663c480 fix(versioning): narrow nullable uuid/description reads flagged by mypy in versioning tests (#44424) 2026-09-24 13:26:30 -07:00
Joe Li
9ad6917600 chore(cypress): remove dead dashboard drill-to-detail Cypress suite (#44537)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:26:29 -07:00
Evan Rusackas
732248e475 fix(logging): record dashboard_id and slice_id for REST mutation endpoints (#44553)
Co-authored-by: vikash kumar <vikkiraj073@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 11:24:11 -07:00
dependabot[bot]
25cb6fc02d chore(deps): bump the ag-grid group in /superset-frontend with 2 updates (#44595)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 10:05:52 -07:00
Đỗ Trọng Hải
613033b294 chore(build): replace external file-related deps with native Node modules (#42939)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-24 22:17:02 +07:00
dependabot[bot]
2ac6e2468d chore(deps-dev): bump webpack-bundle-analyzer from 5.3.3 to 5.4.0 in /superset-frontend (#44598)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:07:54 +07:00
dependabot[bot]
45d76f6aba chore(deps): bump react-draggable from 4.7.1 to 4.7.2 in /superset-frontend (#44597)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:07:44 +07:00
dependabot[bot]
92a3b7d7e3 chore(deps): bump google-auth-library from 11.0.2 to 11.1.0 in /superset-frontend (#44599)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:07:21 +07:00
Devansh Khetan
ad7fcac11f fix(export): stamp ZIP entries with the export time (#44391) 2026-09-24 16:05:39 +02:00
dependabot[bot]
687dcbc63e chore(deps): bump swagger-ui-react from 5.32.15 to 5.33.0 in /docs (#44594)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 20:41:36 +07:00
dependabot[bot]
b4cc82b766 chore(deps-dev): bump jsdom from 30.0.1 to 30.1.0 in /superset-embedded-sdk (#44592)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 20:40:49 +07:00
dependabot[bot]
0ad50dc77e chore(deps-dev): bump jsdom from 30.0.1 to 30.1.0 in /superset-frontend (#44596)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 20:40:20 +07:00
dependabot[bot]
d4370cd46e chore(deps): bump the rjsf group in /superset-frontend with 3 updates (#44593)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 20:37:35 +07:00
Joe Li
8141d666d6 fix(dashboard): show loading spinner for charts outside an in-flight auto-refresh batch (#44272)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-09-23 16:17:57 -07:00
Joe Li
390bf299ff fix(i18n): stop f-strings in gettext leaking a bogus msgid on Python 3.12 (#44584)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 15:34:05 -07:00
Kunal
3b355b6a85 fix(chart): allow semantic view datasource when saving charts (#44169)
Co-authored-by: Gabriel Torres Ruiz <gabo2595@gmail.com>
2026-09-23 15:13:12 -07:00
Shaitan
082961ba16 fix(chart): require edit rights for query-context-only updates (#44494)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-23 22:09:21 +01:00
Prajasree
298aa2f0ba fix(dataset): bump changed_on on column refresh so chart cache invalidates (#43921)
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:34:06 -07:00
Hong Ng
775f7c5a1f fix(db2): Use DATE_TRUNC to improve timegrain expressions (#43236)
Co-authored-by: Hong Yang Ng <hongyang.ng@defence.gov.au>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-09-23 13:21:47 -07:00
Joe Li
d7a918b814 fix(ci): make check_pot_drift extract from an isolated git snapshot (#44578)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-23 12:28:57 -07:00
Gabriel Torres Ruiz
a071d23b41 fix(dashboard): guest sessions, pluggable storage, and streamed downloads for the Excel export (#43805)
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
2026-09-23 15:04:12 -03:00
Amin Ghadersohi
c0c688d3e4 fix(mcp): return a specific error per failure class on tool calls (#44387)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:24:45 -04:00
Enzo Martellucci
7c7b3adf74 fix(export): keep the acting user in streaming CSV exports (#44425)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 15:40:46 +02:00
Mehmet Salih Yavuz
6323b37f50 fix(dashboard): prevent and repair self-nested layout components (#44303) 2026-09-23 16:35:34 +03:00
Mehmet Salih Yavuz
838376dbc2 fix(mongodb): treat the Mongo database as the SQL Lab schema so Data Preview returns rows (#44141) 2026-09-23 16:23:21 +03:00
Enzo Martellucci
02dbfd2ab5 fix(dashboard): don't register an active tab for childless TABS components (#44345)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 14:08:07 +02:00
Enzo Martellucci
689ab4a77a fix(translations): regenerate messages.pot after the connection-move reword (#44574)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 13:30:44 +02:00
dependabot[bot]
f8f293d266 chore(deps): bump benchmark-action/github-action-benchmark from 1.22.1 to 1.22.2 (#44520)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:02:04 -07:00
dependabot[bot]
3e80a53e14 chore(deps-dev): bump @types/node from 26.5.1 to 26.6.1 in /superset-websocket (#44563)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:02:00 -07:00
dependabot[bot]
4290d94d6c chore(deps-dev): bump vitest from 5.0.0 to 5.0.1 in /superset-websocket (#44564)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:01:56 -07:00
dependabot[bot]
4b180bddad chore(deps): bump baseline-browser-mapping from 2.11.23 to 2.11.24 in /docs (#44565)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:01:52 -07:00
dependabot[bot]
6c9138a6b7 chore(deps-dev): bump baseline-browser-mapping from 2.11.23 to 2.11.24 in /superset-frontend (#44566)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:01:48 -07:00
dependabot[bot]
d1f395914f chore(deps-dev): bump @types/node from 26.5.1 to 26.6.1 in /superset-embedded-sdk (#44567)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:01:44 -07:00
dependabot[bot]
6403e16fe5 chore(deps-dev): bump vitest from 5.0.0 to 5.0.1 in /superset-embedded-sdk (#44568)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:01:40 -07:00
dependabot[bot]
7fe03a536b chore(deps-dev): bump @types/node from 26.5.1 to 26.6.1 in /superset-frontend (#44569)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:01:35 -07:00
dependabot[bot]
3b4e5f5d0c chore(deps): bump markdown-to-jsx from 9.10.2 to 9.10.3 in /superset-frontend (#44570)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 03:01:30 -07:00
Amin Ghadersohi
c14ac29f47 fix(mcp): complete Treemap round-trip and previews (#44152) 2026-09-23 02:25:30 -04:00
dependabot[bot]
61fffbe0c0 chore(deps-dev): bump oxlint from 1.82.0 to 1.83.0 in /superset-frontend (#44525)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 11:41:34 +07:00
Bexultan
9612437a9c fix(extensions): preserve chat state across display modes (#44490)
Co-authored-by: Bexultan Mustafin <bexultan.mustafin@ffins.kz>
2026-09-22 18:39:29 -07:00
Amin Ghadersohi
7a9fe06762 fix(mcp): stop data tool responses crashing on binary and NaT values (#44495) 2026-09-22 20:39:01 -04:00
Ujjwal Jain
350dd0d60e fix(security): delete deprecated permissions left over after upgrades (#44142)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-09-22 17:01:32 -07:00
sonfire186
ea1ffcf0ad fix: fixed inverseSelection (#40970)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-09-22 16:52:50 -07:00
Evan Rusackas
50bbcedf13 docs(mcp): document restore_chart and restore_dashboard tools (#44207)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:47:51 -07:00
Elizabeth Thompson
269b9f99fe fix(views): return 401 for NoAuthorizationError in handle_api_exception (SC-121647) (#44533)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 15:02:41 -07:00
Amin Ghadersohi
0f229fda75 fix(security): exempt health blueprint from forced password changes (#44504)
Co-authored-by: GPT-6 Astra <noreply@openai.com>
2026-09-22 18:02:20 -04:00
Amin Ghadersohi
ca7cdfcd0f fix(pinot): require the SQLAlchemy 2 driver extra (#44287) 2026-09-22 18:01:38 -04:00
Evan Rusackas
add97e5d19 fix(embedded): tighten guest-token scoping for annotations, result types, and channels (#43391)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:55:19 -07:00
Evan Rusackas
7dd70eaf2d fix: tighten object-level and destination checks across four unrelated endpoints (#44036)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:31:03 -07:00
Evan Rusackas
48795f9a2d docs(explore): document show_download param for standalone chart embeds (#43906)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:30:35 -07:00
Evan Rusackas
9413294b2b docs(pivot-table): document the "Show values as" percentage display option (#44127)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:24:22 -07:00
320 changed files with 20127 additions and 5459 deletions

View File

@@ -9,6 +9,9 @@ updates:
security:
applies-to: "security-updates"
patterns: ["*"]
codeql:
patterns:
- github/codeql-action/*
cooldown:
default-days: 7
@@ -49,7 +52,11 @@ updates:
# hold comments). Remove this once the proxy code is updated to await
# the async decompress() API.
- dependency-name: "simple-zstd"
directory: "/superset-frontend/"
directories:
- "/superset-frontend"
- "/superset-embedded-sdk"
- "/superset-websocket"
- "/docs"
schedule:
interval: "daily"
labels:
@@ -66,6 +73,11 @@ updates:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
jest:
patterns:
- "jest"
- "babel-jest"
- "jest-environment-jsdom"
babel:
patterns:
- "@babel/*"
@@ -95,7 +107,22 @@ updates:
fontsource:
patterns:
- "@fontsource/*"
open-pull-requests-limit: 30
docusaurus:
patterns:
- "@docusaurus/*"
docusaurus-openapi:
patterns:
- "docusaurus-plugin-openapi-docs"
- "docusaurus-theme-openapi-docs"
monorepo-dependencies:
group-by: dependency-name
patterns:
- "webpack"
- "@types/node"
- "oxfmt"
- "oxlint"
- "oxlint-tsgolint"
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
@@ -133,49 +160,6 @@ updates:
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/docs/"
schedule:
interval: "daily"
groups:
security:
applies-to: "security-updates"
patterns: ["*"]
storybook:
patterns:
- "@storybook/*"
- "storybook"
docusaurus:
patterns:
- "@docusaurus/*"
docusaurus-openapi:
patterns:
- "docusaurus-plugin-openapi-docs"
- "docusaurus-theme-openapi-docs"
typescript-eslint:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-websocket/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
groups:
security:
applies-to: "security-updates"
patterns: ["*"]
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-websocket/utils/client-ws-app/"
schedule:
@@ -191,21 +175,3 @@ updates:
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-embedded-sdk/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
groups:
security:
applies-to: "security-updates"
patterns: ["*"]
babel:
patterns:
- "@babel/*"
versioning-strategy: increase
cooldown:
default-days: 7

View File

@@ -40,7 +40,7 @@ jobs:
persist-credentials: false
- name: Check action refs against the ASF allowlist
uses: apache/infrastructure-actions/allowlist-check@60e90f7a665a68248f145b8b2c97ed712132495b # allowlist-check/v1.0.3
uses: apache/infrastructure-actions/allowlist-check@47b297bbe90f580139129bee531bd84bd95b7f5d # allowlist-check/v1.0.4
with:
# Default scan-glob is .github/**/*.yml, which misses .yaml files.
scan-glob: ".github/**/*.y*ml"

View File

@@ -352,14 +352,10 @@ playwright-run() {
return 0
fi
echo "Running tests: ${TEST_PATH}"
# Set INCLUDE_EXPERIMENTAL=true to allow experimental tests to run
export INCLUDE_EXPERIMENTAL=true
npx playwright test "${TEST_PATH}" --output=playwright-results
local status=$?
# Unset to prevent leaking into subsequent commands
unset INCLUDE_EXPERIMENTAL
else
echo "Running all required tests (experimental/ excluded via playwright.config.ts)"
echo "Running all default-project tests"
npx playwright test --output=playwright-results
local status=$?
fi

View File

@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -78,6 +78,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
category: "/language:${{matrix.language}}"

View File

@@ -482,7 +482,7 @@ jobs:
contains(fromJson('["superset", "lean"]'), matrix.build_preset) &&
steps.grype-scan.outputs.sarif != ''
continue-on-error: true
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
sarif_file: ${{ steps.grype-scan.outputs.sarif }}
# Both `superset` and `lean` are scanned now; without a distinct

View File

@@ -100,7 +100,7 @@ jobs:
# No PR to comment on here, so comment-on-alert is off -- the job
# summary (summary-always) is the only surface for this run.
- name: Update bundle size baseline
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
uses: benchmark-action/github-action-benchmark@4322e5726e6334590d251fc4f92bec0efafc45dc # v1.22.2
with:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json

View File

@@ -204,9 +204,8 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-26.04
# Embedded Tests below adds its own gunicorn boot + spec run on top of
# Required and Soft-delete; 30m was tight even for the two-step shadow
# job this replaced.
# Soft-delete, Embedded and Mobile each add their own gunicorn boot +
# spec run on top of the Required step.
timeout-minutes: 40
permissions:
contents: read
@@ -381,6 +380,23 @@ jobs:
INCLUDE_EMBEDDED: "true"
with:
run: playwright-run "${{ matrix.app_root }}" embedded
- name: Run Playwright (Mobile Tests)
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
env:
NODE_OPTIONS: "--max-old-space-size=4096"
# Scoped to this step for the same reason as Soft-delete and
# Embedded above: the mobile consumption mode flag must not alter
# Flask's configuration for the other steps' servers.
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
INCLUDE_MOBILE: "true"
with:
run: playwright-run "${{ matrix.app_root }}" mobile/
- name: Set safe app root
if: failure()
id: set-safe-app-root

View File

@@ -300,7 +300,7 @@ jobs:
# failure) -- it propagates and fails the job outright. Fork PRs
# still get the comparison via the job summary (summary-always).
- name: Compare bundle size against nightly baseline
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
uses: benchmark-action/github-action-benchmark@4322e5726e6334590d251fc4f92bec0efafc45dc # v1.22.2
with:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json

View File

@@ -1,222 +0,0 @@
name: Playwright Experimental Tests
on:
push:
branches:
- "master"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
workflow_dispatch:
inputs:
ref:
description: "The branch or tag to checkout"
required: false
default: ""
pr_id:
description: "The pull request ID to checkout"
required: false
default: ""
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
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: $/.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests),
# including Embedded — it moved out of this workflow because embedding is a
# required feature, not an experimental one. This workflow now contains
# only experimental and mobile tests, which run in shadow mode.
playwright-tests-experimental:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-26.04
timeout-minutes: 30
continue-on-error: true
permissions:
contents: read
pull-requests: read
strategy:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ["", "/app/prefix"]
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
ports:
- 15432:5432
redis:
image: redis:7-alpine
ports:
- 16379:6379
steps:
# -------------------------------------------------------
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Setup Python
uses: $/.github/actions/setup-backend/
- name: Setup postgres
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: setup-postgres
- name: Import test data
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: playwright_testdata
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: "./superset-frontend/.nvmrc"
cache: "npm"
cache-dependency-path: "superset-frontend/package-lock.json"
- name: Install npm dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: npm-install
- name: Build javascript packages
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: build-instrumented-assets
- name: Install Playwright
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: playwright-install
- name: Run Playwright (Experimental Tests)
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
run: playwright-run "${{ matrix.app_root }}" experimental/
- name: Run Playwright (Mobile Tests)
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
env:
NODE_OPTIONS: "--max-old-space-size=4096"
# Scoped to this step: setting feature flags at the job level would
# alter Flask's configuration for the preceding Experimental step
# too — the mobile consumption mode should not do that.
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
INCLUDE_MOBILE: "true"
with:
run: playwright-run "${{ matrix.app_root }}" mobile/
- name: Set safe app root
if: failure()
id: set-safe-app-root
run: |
APP_ROOT="${{ matrix.app_root }}"
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Playwright Artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: failure()
with:
path: |
${{ github.workspace }}/superset-frontend/playwright-results/
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-experimental-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
actions-timeline:
needs: playwright-tests-experimental
if: always()
runs-on: ubuntu-slim
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true

View File

@@ -24,6 +24,19 @@ assists people when migrating to a new version.
## Next
### Guest token RLS rules without a dataset apply inside sub-queries
A guest token RLS rule with no `dataset` key applies to every dataset. Such
rules are now also injected into sub-queries of custom SQL expressions (with
`ALLOW_ADHOC_SUBQUERY` enabled) and into SQL Lab queries, for every table that
resolves to a dataset, instead of only into the chart's outer query. A virtual
dataset's inner SQL is unchanged, since its outer query already applies them.
If a sub-query reads a dataset that lacks a column the rule references, the
query now fails with a column-not-found error instead of reading rows the rule
was meant to exclude. To keep such charts working, set `dataset` on the rule so
it only targets the datasets that have the column.
### MCP response size guard: byte limit instead of estimated token count
The MCP response-size guard no longer estimates LLM token counts (it
@@ -87,6 +100,8 @@ error notice. This does not authorize replay of data-bearing notifications.
its Presto dialect under SQLAlchemy 2 because it imports `sqlalchemy.databases`.
Upgrade existing installations with `pip install "pyhive[presto]>=0.7.0"`.
- The Pinot extra requires pinotdb[sqlalchemy]>=8.0.0,<10.0.0. Earlier releases declare SQLAlchemy below 2 in their SQLAlchemy extra.
- The Databricks extra requires databricks-sqlalchemy 2.x (at least 2.0.1). The 1.x dialect requires SQLAlchemy below 2.
- `superset deletion-retention force-purge` now exits **1** when the target is
@@ -94,6 +109,34 @@ error notice. This does not authorize replay of data-bearing notifications.
scripted compliance erasure cannot mistake a refusal for a completed purge.
Only a completed purge exits 0; a usage error still exits 2.
### Deprecated permission cleanup may change custom role grants
Two migrations now clean up permissions deprecated in past releases that
previously stuck around forever after an upgrade (#33272). If a custom role
holds one of these permissions, upgrading will either:
- **Delete it outright**, for permissions whose underlying feature has no
live equivalent (e.g. the access-request workflow), or whose only live
successor would grant a role a materially broader capability than it ever
had -- for example `can_testconn` is deleted rather than resurrected as
`Database.can_write`, which would let the role create, edit, or delete any
database connection, not just test one; or
- **Migrate it to a verified live successor** (e.g. `can_explore_json` ->
`can_read` on Chart), preserving the role's effective access.
`can_copy_dash` is the exception to this rule. Although its live successor,
`Dashboard.can_write`, is broader than the historical permission, the current
dashboard-copy endpoint is itself authorized by `Dashboard.can_write`. It is
therefore migrated rather than deleted so existing access to dashboard
copying is preserved.
If a custom role in your deployment relies on one of the deleted
permissions, re-grant the appropriate live permission to it manually after
upgrading. See the two migrations' docstrings (`superset/migrations/versions/
2026-09-10_00-00_1f5f4fb8bfc1_delete_deprecated_permissions_33272.py` and
`..._00-01_3ce9a4572f8a_rename_deprecated_permissions_33272.py`) for the full
per-permission mapping and reasoning.
### MySQL metadata database now actually defaults to READ COMMITTED
Superset has always *intended* to default the metadata-database isolation
@@ -169,6 +212,16 @@ Scheduled report and alert captures require chart readiness to remain stable
immediately before Chromium captures the image. A capture that re-enters a loading
state during that window fails instead of delivering a screenshot with spinners.
### Improve Db2 Time Grain Expressions
The Db2 engine spec has been streamlined by using the DATE_TRUNC scalar function,
which requires Db2 11.1.0 or higher. Per the ISO 8601 standards, the `WEEK` time
grain now shifts the first day of the week to Monday as part of this change.
### Update IBM Db2 for i Time Grain Expressions
IBM Db2 for i inherits its engine spec from Db2 but does not support the DATE_TRUNC
scalar function, so it will use the previous arithmetic expressions defined for Db2.
Its `WEEK` time grain now uses `DAYOFWEEK_ISO` to align with the Db2 change.
### Scheduled rendered reports fail closed after capture rejection
Scheduled PDF and PNG delivery requires an accepted report capture context. A
@@ -873,19 +926,51 @@ Note that a retried query returns partial data with no truncation indicator
(e.g. a filter dropdown may list only a subset of values on tables above the
row cap).
### Dashboard "Export Data to Excel" requires a Celery worker and S3 bucket
### Dashboard "Export Data to Excel" moves from `EXCEL_EXPORT_S3_*` to `EXPORT_STORAGE`
A new dashboard action exports every chart's data to a single multi-sheet
`.xlsx` asynchronously. It is disabled by default and turns on only when
`EXCEL_EXPORT_S3_BUCKET` is set (the endpoint returns `501` otherwise). It also
requires a running Celery worker and a configured SMTP transport, since the task
emails the requesting user a pre-signed download link. New config keys:
`EXCEL_EXPORT_S3_BUCKET`, `EXCEL_EXPORT_S3_KEY_PREFIX`,
`EXCEL_EXPORT_LINK_TTL_SECONDS`, `EXCEL_EXPORT_S3_CLIENT_KWARGS`,
`EXPORT_STORAGE` is configured with both a `bucket` and a `backend` (the
endpoint returns `501` otherwise) — there is no implicit storage default:
```python
from superset.utils.s3 import S3ExportStorage # or superset.utils.gcs.GCSExportStorage
EXPORT_STORAGE = {
"bucket": "my-export-bucket",
"backend": S3ExportStorage(),
}
```
**Upgrading from `EXCEL_EXPORT_S3_*`:** the S3-only config keys are removed and
replaced by the pluggable `EXPORT_STORAGE` above. They are no longer read, so a
deployment that had the export working keeps a valid-looking config while the
endpoint starts returning `501`. Port each key:
| Removed | Replacement |
| --- | --- |
| `EXCEL_EXPORT_S3_BUCKET = "my-bucket"` | `EXPORT_STORAGE["bucket"] = "my-bucket"` |
| `EXCEL_EXPORT_S3_KEY_PREFIX = "prefix/"` | `EXPORT_STORAGE["key_prefix"] = "prefix/"` |
| `EXCEL_EXPORT_S3_CLIENT_KWARGS = {...}` | `EXPORT_STORAGE["backend"] = S3ExportStorage(client_kwargs={...})` |
`EXPORT_STORAGE["backend"]` has no default and must be set explicitly, which is
the part an upgrade cannot infer: the previous config implied S3, so keep the
same bucket with `S3ExportStorage()`. `EXCEL_EXPORT_LINK_TTL_SECONDS` is
unchanged in name, but it now bounds a Superset-issued link rather than a
pre-signed S3 URL, so the AWS seven day ceiling no longer applies.
It also requires a running Celery worker. SMTP is optional and only used to
additionally email logged-in users a download link; every session (including
guest/Public ones, which have no email) gets the export through status polling
and automatic download. Config keys:
`EXPORT_STORAGE`, `EXCEL_EXPORT_LINK_TTL_SECONDS`,
`EXCEL_EXPORT_TABLE_VIZ_TYPES`, and `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
The feature depends on `boto3`, which is **not** installed by default; install it
with `pip install apache-superset[excel-export]`.
The storage backends depend on SDKs that are **not** installed by default:
install `pip install apache-superset[excel-export]` (boto3) for
`S3ExportStorage`, or `pip install apache-superset[excel-export-gcs]`
(google-cloud-storage) for `GCSExportStorage`. A custom backend can be supplied
by implementing `superset.utils.export_storage.ExportStorage`.
Charts store their `query_context` only once they have been (re-)saved in
Explore, so older charts may have none. For a fixed, conservative set of viz

View File

@@ -348,6 +348,13 @@ may need to utilize a healthcheck endpoint so that your load balancer knows if y
instance is running. This is provided at `/health` which will return a 200 response containing “OK”
if the webserver is running.
Routes on the health blueprint, including `/health`, `/healthcheck`, `/ping`, and
`/version`, are exempt from forced password changes. Even with
`ENABLE_FORCE_PASSWORD_CHANGE = True`, a probe carrying a session cookie for a user
with a pending password change receives the route's normal response rather than a
redirect to the password-reset form. This exemption does not bypass other security
checks or change which version details the user may see.
If the load balancer is inserting `X-Forwarded-For/X-Forwarded-Proto` headers, you should set
`ENABLE_PROXY_FIX = True` in the superset config file (`superset_config.py`) to extract and use the
headers.

View File

@@ -762,8 +762,8 @@ MCP_TOOL_SEARCH_CONFIG = {
],
"search_tool_name": "search_tools",
"call_tool_name": "call_tool",
"include_schemas": False, # False=summary mode (name + parameters_hint)
"compact_schemas": True, # Strip $defs (only applies when include_schemas=True)
"include_schemas": True, # False=summary mode (name + parameters_hint)
"compact_schemas": True, # Default description limit when include_schemas=True
"max_description_length": 300,
}
```
@@ -774,8 +774,8 @@ MCP_TOOL_SEARCH_CONFIG = {
| `strategy` | `"bm25"` | Search ranking algorithm. `"bm25"` supports natural language; `"regex"` supports pattern matching |
| `max_results` | `5` | Maximum tools returned per search query |
| `always_visible` | See above | Tools that always appear in `list_tools`, regardless of search |
| `include_schemas` | `False` | When `False` (default, "summary mode"), search results omit `inputSchema` entirely and include a lightweight `parameters_hint` listing top-level parameter names. Set to `True` to include the full `inputSchema` in search results. Full schemas are always used when a tool is actually invoked via `call_tool`. |
| `compact_schemas` | `True` | Strip `$defs` / `$ref` and replace with `{"type": "object"}` in search results to reduce token cost. Only takes effect when `include_schemas=True` — ignored in summary mode. |
| `include_schemas` | `True` | Include the full `inputSchema` in search results by default. When `False` ("summary mode"), search results omit `inputSchema` entirely and include a lightweight `parameters_hint` listing top-level parameter names. Full schemas are always used when a tool is actually invoked via `call_tool`. |
| `compact_schemas` | `True` | Legacy setting selecting the default description limit (300 when `True`, 0 when `False`) if `max_description_length` is omitted. Input schemas preserve `$defs`, `$ref`, nullable unions, and constraints in either mode; titles are omitted. Clients should resolve references within each tool's `inputSchema`. |
| `max_description_length` | `300` | Truncate tool descriptions in search results (0 = no truncation). Applies in both summary and full-schema modes. |
:::tip
@@ -1020,6 +1020,23 @@ while True:
page += 1
```
## Trash Listing (Soft-Deleted Objects)
When the `SOFT_DELETE` feature flag is enabled, deleting a chart or dashboard moves it to trash rather than removing it outright. The `list_charts` and `list_dashboards` tools accept a `deleted_state` parameter to surface those trashed objects:
- `deleted_state="only"` — return only trashed objects
- `deleted_state="include"` — return live and trashed objects together
- Omitted (default) — live objects only; trashed objects are excluded
```python
# List only trashed charts
result = mcp.list_charts(request={"deleted_state": "only"})
```
Trashed rows carry a non-null `deleted_at` timestamp in the response (populated only when `deleted_state` is used). Visibility follows the same restore-audience scoping as the equivalent REST API filters (`chart_deleted_state` / `dashboard_deleted_state`): a non-admin caller sees only the trashed objects they can edit (the same audience that can restore them), not merely the ones they own; admins see all trashed objects. That scoping is narrower than the true restore audience in two cases it can't express in SQL: editorship granted via `EXTRA_EDITORS_RESOLVER` and guest users whose editorship derives from roles. Callers in either case may be able to restore a trashed object via `POST /restore` yet not see it listed here.
`deleted_state` is only defined on `list_charts` and `list_dashboards`. Other list tools don't declare the field at all, so passing it is silently ignored rather than rejected — it never returns trashed objects.
## Disabling built-in tools
If you have deployed a custom tool via a Superset extension that supersedes one of the built-in Superset tools, you can suppress the built-in version so AI clients only discover your replacement. Disabled tools are removed from the server at startup and are never advertised during tool discovery.
@@ -1048,7 +1065,7 @@ Specifying a tool name that does not exist logs a warning at startup and is othe
## Disabling chart type plugins
The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `treemap_v2`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
Gauge data inspection and JSON/CSV/XLSX exports preserve source groups, including NULL and non-finite metric values, using the same row-count and completeness semantics as other chart types. The default XLSX exporter writes non-finite floats as `nan`, `inf`, or `-inf` text, matching CSV and distinguishing them from blank NULL cells. Only previews and compile checks skip NULL, NaN, non-finite, and other nonnumeric dial values while preserving finite groups. In preview/compile paths, a nonempty result containing no finite dial returns an error; genuinely empty results retain the no-data behavior. Compile checks inspect the configured dial limit (at most 10), rather than only two groups. Grouped Vega previews center each needle within its own facet and extend it to the midpoint of the dial band. Automatic all-zero ranges span 0–1. Empty or whitespace-only temporal ranges, like `No filter`, are neutral sentinels; missing or non-string native temporal comparators are invalid.
@@ -1175,6 +1192,37 @@ answer. Dashboard access checks and row-level security remain independent of thi
display-context projection. Integrations must ensure the lookup actually happens
and the assistant uses the result; tool guidance alone cannot guarantee this.
## Tool-call errors
For exceptions raised by tools, the shared error handler distinguishes permission
denials, invalid arguments, datasource failures, invalid SQL, and internal errors.
Datasource responses include an enumerated reason rather than raw driver output;
internal errors return an error ID rather than implementation details. Tools such
as `get_chart_data` that return structured error envelopes instead of raising do
not pass through this handler.
Missing tables, columns, and schemas log at `WARNING` without invoking
`MCP_ERROR_HOOK`, even when the exception inherits status 500. Connection failures
with status 500 or higher remain `ERROR` and invoke the hook. An explicitly
sub-500 exception remains a warning even if it carries a connection error reason:
the generic engine reason can also describe a caller's malformed column, and a
connection access denial is still a permission failure.
## Result serialization
Data-bearing tool responses sanitize row values, column samples, and statistics
even when Python producers supply tuples, sets, generators, or read-only mappings.
Missing and non-finite values become JSON `null`; non-UTF-8 binary values become
base64 strings prefixed with `base64:`. Boolean row counts are treated as
unavailable, producing `null` for nullable counts and `0` for non-nullable counts,
rather than being interpreted as one or zero rows.
## Chart data column counts
For saved and unsaved charts, `get_chart_data` column metadata excludes missing
values from `unique_count`. Values serialized as JSON `null`, including NaN,
NaT, and non-finite numbers, contribute to `null_count` instead.
## Dataset query dimensions and nested fields
The `query_dataset` tool accepts exact column names registered on the selected
@@ -1197,6 +1245,65 @@ The tool does not rewrite dots into SQL path separators: quoting and nested-fiel
support depend on the dataset's database dialect, and dots can also be literal
characters in column names.
### Treemap charts
Saved and cached previews honor the configured Treemap row limit across ASCII,
table, and Vega representations. SQL Decimal metrics are accepted when finite;
booleans and nonfinite values are rejected. Categories with identical display
names but distinct raw types retain separate geometry groups. Currency settings
use the native `symbolPosition` property. Adding filters while omitting the
temporal column preserves saved predicates and the existing neutral time binding;
explicit empty filters or a cleared temporal column remove those controls.
Use `chart_type: "treemap_v2"` with an ordered `groupby` hierarchy and one
`metric`. Hierarchy columns accept typed references (`{"name": "region"}`)
or native strings. Metrics accept typed column references with an aggregate,
saved metric names, or native SIMPLE/SQL metric objects. SQL metrics still
require a label and pass the shared SQL-expression validation. Unknown controls
and executable JavaScript hooks are not accepted.
```json
{
"dataset_id": 7,
"config": {
"chart_type": "treemap_v2",
"groupby": ["region", "product"],
"metric": {"name": "revenue", "aggregate": "SUM"},
"row_limit": 100,
"sort_by_metric": true,
"show_labels": true,
"show_upper_labels": true,
"label_type": "key_value",
"number_format": ",.2f"
}
}
```
Bounded queries sort by the metric descending when requested, then by hierarchy
columns ascending to break ties, matching the frontend query contract.
`update_chart` and `update_chart_preview` preserve omitted same-dataset Treemap
controls, including hierarchy, metrics, formatting, filters, temporal state and
`template_params`. Partial configurations are accepted only for updates; a new
chart still requires hierarchy and metric roles. Explicit empty filters clear
filters, and explicit null clears nullable controls. Rebinding to another dataset
requires complete roles and inherits only supported presentation controls.
ASCII and table previews show hierarchy and values. Vega-Lite previews use
bounded slice-and-dice rectangles with metric-proportional areas, hierarchy
paths, categorical colors, labels and tooltips. This is not the native ECharts
layout: use the Explore URL for native formatting and interactions. The preview
supports up to 1,000 nonnegative rows, 20 hierarchy levels, and the built-in
`supersetColors` and `lyftColors` schemes. Unsupported color/currency formats,
negative values, zero totals and over-limit geometry return
`UnsupportedTreemapPreview` rather than a substitute scatter or bar chart.
`SMART_NUMBER` is approximated with SI formatting in this preview.
Queries must return every hierarchy output and the metric's exact output label.
Malformed/error envelopes and nonnumeric or nonfinite metric outputs return
structured errors instead of apparently successful previews, charts or exports.
## Dataset discovery and routing
`list_datasets` searches table name, **description**, schema, and SQL using

View File

@@ -220,7 +220,7 @@ npm run cypress-debug
npm run cypress-run-chrome
# Run specific file
npm run cypress-run-chrome -- --spec "cypress/e2e/dashboard/dashboard.test.ts"
npm run cypress-run-chrome -- --spec "cypress/e2e/explore/chart.test.js"
```
### Debugging Server App

View File

@@ -46,6 +46,10 @@ The host supports two display modes, switchable by the user or the extension at
The user's last selected mode and open/closed state are persisted across page reloads.
Switching display modes keeps the same panel mounted, preserving its conversation,
draft, expanded tool results, and in-flight UI state. Closing the chat still
unmounts the panel; extensions remain responsible for their cleanup on unmount.
## Registering a Chat
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:

View File

@@ -234,6 +234,23 @@ The AI agent sees your tool's:
### Input Validation Errors
Argument-validation failures return MCP `isError: true`, with schema field paths
(such as `request.page_size`) and safe reasons (such as `Expected an integer`).
Supply required wrappers such as `request` rather than passing their fields at
the top level. An unexpected top-level argument declared directly under `request`
gets its expected schema path and a wrapper hint, for example:
`Validation error in list_datasets: request.page_size: Unexpected top-level argument (place under request)`.
Diagnostics omit received values and custom validator messages. Undeclared fields
and dynamic dictionary keys appear as `[field]`; dictionary keys and nested
extras remain masked even when they match a field declared elsewhere in the
schema. Apart from the top-level wrapper hint, only fields declared at the
corresponding schema path are shown. Responses include at most eight validation
errors with at most eight path segments each. Failures with more than 128 errors
return an input-free summary instead of individual details, avoiding eager
materialization of the entire error collection for diagnostics.
Already-structured tool errors retain their content and error flag; this
validation formatter does not reinterpret domain-error payloads.
1. **Pydantic models**: Ensure field types match expected inputs
2. **Field constraints**: Check min/max values and string lengths are reasonable
3. **Required fields**: Verify which parameters are required vs optional

View File

@@ -139,6 +139,27 @@ The following URL parameters can be passed through the `urlParams` option in `da
---
## Embedding a Single Chart
Individual charts can also be embedded standalone, outside of a dashboard, using a chart permalink. From Explore, generate a permalink for the chart, then append URL parameters to it:
```
/explore/p/<permalink-key>/?standalone=1&show_download=1
```
| Parameter | Values | Effect |
| --------------- | -------- | -------------------------------------------------------------------------- |
| `standalone` | `0`, `1` | `0`: normal Explore view; `1`: hide the Explore header and controls |
| `show_download` | `0`, `1` | Show a compact download control (CSV, JSON, Excel) on the standalone chart |
`show_download` is opt-in and has no effect outside of `standalone=1`. The download control still respects the viewer's export permissions — it's hidden for users who lack them even when the parameter is set. With the `GRANULAR_EXPORT_CONTROLS` feature flag enabled, this requires the `can_export_data` permission on `Superset`; otherwise it falls back to `can_csv` on `Superset`.
Explore's **Embed Code** button on a chart also generates one of these permalink URLs wrapped in an `<iframe>`, giving you a session-authenticated iframe embed. That's a separate flow from dashboard embedding above: it doesn't go through `@superset-ui/embedded-sdk`, guest tokens, or the `dashboardUiConfig` options — the viewer needs an existing Superset session with the `can read on Explore` permission, in addition to access to the chart itself. Viewing the same chart on a dashboard doesn't require that permission, so a role scoped only for dashboard viewing gets an access denial on this URL.
One caveat when the host page lives on a different site than Superset: the default `SESSION_COOKIE_SAMESITE = "Lax"` setting keeps the session cookie out of cross-site iframe requests, so the viewer lands on the login page instead of the chart. Serving both from the same site avoids this; otherwise set `SESSION_COOKIE_SAMESITE = "None"` together with `SESSION_COOKIE_SECURE = True` and allow the host origin in your `TALISMAN_CONFIG` `frame-ancestors`. Even with that configuration, browsers that block third-party cookies by default (including Safari) can still redirect the viewer to the login page. For those cases, host the iframe on the same site as Superset, or use the dashboard embedding SDK's guest-token flow above instead.
---
## Security Notes
- **Guest tokens expire** — their lifetime is controlled by the `GUEST_TOKEN_JWT_EXP_SECONDS` config (default: 5 minutes). Refresh tokens before they expire using a token refresh mechanism in your host app.

View File

@@ -170,6 +170,20 @@ control can override a simple metric's own aggregation (to Sum or Average) for t
only — metrics built from custom SQL keep their own aggregation regardless.
:::
In the **Options** panel, the **Show values as** control displays each cell as a percentage of its
row total, column total, or grand total instead of its actual value:
:::note
Totals and subtotals are still computed correctly first, as described above; **Show values as**
only changes how the already-correct cell, row-total, column-total, and grand-total values are
displayed, not how they're calculated. Switching it requires rerunning the query, though: for
non-additive metrics, a percentage option can pull in an additional rollup level for its
denominator even when the matching Total row/column isn't shown, so select **Run Query** after
changing this control to see the correct percentages. Because the result is always a percentage, a
per-metric custom formatter (currency, decimal precision, etc.) doesn't apply while a fraction
option is selected.
:::
### Line Chart
In this section, we are going to create a line chart to understand the average price of a ticket by

View File

@@ -9,16 +9,18 @@ version: 1
Superset can export every chart on a dashboard to a single Excel workbook, with
each chart's underlying data rendered as its own worksheet. The export reflects
the dashboard's currently applied filters and runs asynchronously: when it
finishes, the requesting user receives an email with a time-limited download
link.
the dashboard's currently applied filters and runs asynchronously: the page
polls for completion and downloads the workbook automatically, and a logged-in
user with an email address also receives a time-limited download link by email.
Sessions with no email on file — embedded guest-token sessions and anonymous
(Public-role) users — rely on the polling download alone.
## Using the export
From a dashboard, open the **... (actions) → Download** submenu and choose
**Export Data to Excel**. The action appears for users who have the dashboard
`can_export` permission. You'll see a confirmation that the export is being
prepared; the workbook arrives by email when it's ready.
prepared; the workbook downloads automatically when ready.
A second option, **Export Images to Excel**, embeds each non-table chart as a
rendered image (tables stay tabular) instead of exporting raw data. Because it
@@ -36,8 +38,9 @@ Notes on the generated workbook:
re-saved in Explore) still exports when it is a `table`, `big_number`,
`big_number_total` or `pie`, by rebuilding the query from the chart's saved
form data. Charts of other types — and charts relying on post-processing the
rebuild can't reproduce — are skipped and listed in the email; open the chart
in Explore and re-save it to include it next time, or configure
rebuild can't reproduce — are skipped and listed in an **Export Summary**
worksheet added to the workbook (and in the email, when one is sent); open
the chart in Explore and re-save it to include it next time, or configure
`EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
- Row counts per sheet are capped the same way as the chart-level CSV/Excel
export (`ROW_LIMIT`, bounded by `SQL_MAX_ROW`), and never exceed Excel's
@@ -47,17 +50,47 @@ Notes on the generated workbook:
This feature is **disabled by default**. It requires:
1. **The `boto3` dependency.** It is not installed by default; install it with
`pip install apache-superset[excel-export]`. Without it, exports fail and the
user receives a failure email.
2. **An S3 bucket.** Set `EXCEL_EXPORT_S3_BUCKET`. Until it is set, the export
endpoint returns `501` and the menu action surfaces a "not configured"
message.
1. **A storage bucket and backend.** Configure `EXPORT_STORAGE` with both
a `bucket` and a `backend` matching the bucket's provider — there is no
implicit default:
```python
from superset.utils.s3 import S3ExportStorage # AWS S3
# from superset.utils.gcs import GCSExportStorage # Google Cloud Storage
EXPORT_STORAGE = {
"bucket": "my-export-bucket",
"backend": S3ExportStorage(),
}
```
Until both are set, the export endpoint returns `501` and the menu action
surfaces a "not configured" message.
:::note Upgrading from `EXCEL_EXPORT_S3_*`
`EXCEL_EXPORT_S3_BUCKET`, `EXCEL_EXPORT_S3_KEY_PREFIX` and
`EXCEL_EXPORT_S3_CLIENT_KWARGS` were replaced by `EXPORT_STORAGE` and are no
longer read. Move the bucket and prefix across, pass the client kwargs to
`S3ExportStorage(client_kwargs=...)`, and set `backend` explicitly: it has no
default, so an export that worked before returns `501` until it is set.
:::
2. **The backend's SDK dependency, on both the web and worker tiers** (the
worker uploads, the web server streams downloads). Not installed by
default; install
`pip install apache-superset[excel-export]` (boto3) for `S3ExportStorage`,
or `pip install apache-superset[excel-export-gcs]` (google-cloud-storage)
for `GCSExportStorage`. Without it, exports fail.
3. **A running Celery worker.** The export runs as a Celery task. If no worker
is running, the request is accepted but nothing is produced.
4. **A configured SMTP transport.** The download link is delivered by email
using the same settings as alerts & reports (`SMTP_*`,
`EMAIL_REPORTS_SUBJECT_PREFIX`).
4. **A configured SMTP transport, for email delivery only.** When set (same
settings as alerts & reports: `SMTP_*`, `EMAIL_REPORTS_SUBJECT_PREFIX`),
logged-in users with an email address also receive the download link by
email. The polling auto-download works without it. The emailed link is
absolute and built from `WEBDRIVER_BASEURL_USER_FRIENDLY` (default
`http://0.0.0.0:8080/`), so set it to your public Superset origin
(e.g. `WEBDRIVER_BASEURL_USER_FRIENDLY = "https://superset.example.com/"`)
or remote recipients get an unreachable link.
**Export Images to Excel** additionally requires a working headless webdriver —
the same infrastructure scheduled reports and thumbnails use (`WEBDRIVER_*`,
@@ -72,33 +105,48 @@ will not register.
## Configuration keys
| Key | Default | Description |
| ------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EXCEL_EXPORT_S3_BUCKET` | `None` | Destination bucket. Required; `501` if unset. |
| `EXCEL_EXPORT_S3_KEY_PREFIX` | `"dashboard-exports/"` | Key prefix: `{prefix}{dashboard_id}/{job_id}.xlsx`. |
| `EXCEL_EXPORT_LINK_TTL_SECONDS` | `86400` | Lifetime of the pre-signed download URL (24h). |
| `EXCEL_EXPORT_S3_CLIENT_KWARGS` | `{}` | Extra kwargs for `boto3.client("s3", ...)` — e.g. `region_name`, or `endpoint_url` for MinIO/LocalStack. |
| `EXCEL_EXPORT_TABLE_VIZ_TYPES` | `None` | Viz types kept tabular in **Export Images to Excel** mode; every other type is embedded as an image. `None` uses the built-in default (`table`, `pivot_table`, `pivot_table_v2`). |
| `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER` | `None` | Optional `Callable[[form_data_dict], dict \| None]` to build a query context for a chart missing a saved one, tried before the built-in form-data rebuild. Point it at a service that runs the chart's real frontend `buildQuery` to faithfully export viz types the built-in rebuild can't handle. Must return `None` when it can't build faithfully, so the export falls back. |
| Key | Default | Description |
| -------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EXPORT_STORAGE["bucket"]` | unset | Destination bucket. Required; `501` if unset. |
| `EXPORT_STORAGE["backend"]` | unset | Storage backend instance: `S3ExportStorage()` (`superset.utils.s3`), `GCSExportStorage()` (`superset.utils.gcs`), or a custom `superset.utils.export_storage.ExportStorage` implementation. Required; `501` if unset. |
| `EXPORT_STORAGE["key_prefix"]` | `"dashboard-exports/"` | Object key/blob prefix: `{prefix}{dashboard_id}/{job_id}.xlsx`. A callable (`() -> str`) is invoked per export **inside the Celery worker** (app context only, no request context; reading `flask.request` fails every export) — derive per-tenant prefixes from worker-ambient app config. |
| `EXCEL_EXPORT_LINK_TTL_SECONDS` | `86400` | Lifetime of the Superset download link (24h) shared in the email and polling response. Each click streams the file from storage through Superset. Guest-token exports cap the link at one hour regardless of this value, since a guest retrieves the file within the polling window and has no email link to revisit later. |
| `EXCEL_EXPORT_TABLE_VIZ_TYPES` | `None` | Viz types kept tabular in **Export Images to Excel** mode; every other type is embedded as an image. `None` uses the built-in default (`table`, `pivot_table`, `pivot_table_v2`). |
| `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER` | `None` | Optional `Callable[[form_data_dict], dict \| None]` to build a query context for a chart missing a saved one, tried before the built-in form-data rebuild. Point it at a service that runs the chart's real frontend `buildQuery` to faithfully export viz types the built-in rebuild can't handle. Must return `None` when it can't build faithfully, so the export falls back. |
Credentials and region resolve through the standard boto3 chain (environment
variables, shared config, or instance role) unless overridden via
`EXCEL_EXPORT_S3_CLIENT_KWARGS`. The worker needs `s3:PutObject` on the bucket.
Credentials resolve through each SDK's standard chain — for S3, environment
variables, shared config, or an instance role, with overrides available via
`S3ExportStorage(client_kwargs={...})` (e.g. `region_name`, or `endpoint_url`
for MinIO/LocalStack); for GCS, Application Default Credentials. Two tiers
need bucket access, which matters when they run under separate identities:
the **Celery worker** uploads the file (write, e.g. `s3:PutObject`), while
the **web server** streams it back at download time (read, e.g.
`s3:GetObject`; on S3 also grant `s3:ListBucket`, or a lifecycle-expired
object surfaces as AccessDenied instead of a clean "link expired"). No
signing permissions are needed on either tier: downloads stream through
Superset rather than redirecting to a signed storage URL.
## Security considerations
- The emailed link is a **pre-signed S3 URL**: anyone who holds it can download
the workbook until it expires. Keep the bucket **private**, enable
encryption, and consider a lifecycle rule to delete objects after a few days.
Lower `EXCEL_EXPORT_LINK_TTL_SECONDS` if 24 hours is too long for your data.
- The download link is an unguessable Superset URL: anyone who holds it can
download the workbook until the link expires, and every download streams
through Superset (never a transferable signed storage URL). Keep the bucket
**private**, enable encryption, and consider a lifecycle rule to delete
objects after a few days. Lower `EXCEL_EXPORT_LINK_TTL_SECONDS` if 24 hours
is too long for your data.
- The export runs with the requesting user's permissions; each chart's query is
access-checked, so users only ever receive data they are entitled to.
## Limitations
- **Embedded dashboards / guest tokens are not supported** in this version,
because guest users have no email address to deliver the link to. Logged-in
users viewing an embedded dashboard can still use the export.
- **Embedded guest-token sessions get the polling download only.** The export
runs under the guest token's RLS rules and resource claims, and the page
polls for completion and downloads automatically — but there is no email
fallback, so the browser tab must stay open until the export finishes.
**Export Images to Excel** is not available to guest or anonymous
(Public-role) sessions: the webdriver cannot render without a real user
identity, so the menu hides it and the API rejects it for any session
without a user id.
- The default **Export Data to Excel** mode exports data only (no visual
styling). Use **Export Images to Excel** to embed rendered chart images, which
requires the webdriver infrastructure described in the prerequisites.

View File

@@ -281,6 +281,7 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
| `update_chart_preview` | Update a cached chart preview without saving |
| `generate_explore_link` | Generate an Explore URL for interactive visualization |
| `delete_chart` | Delete a chart by ID or UUID (soft-deletes to trash when `SOFT_DELETE` is enabled; fails if alerts/reports are still attached, checked before editorship; otherwise returns `permission_denied` if the caller isn't an editor of the chart — owners, Admins, and explicitly granted editors qualify) |
| `restore_chart` | Restore a soft-deleted chart from trash by ID or UUID; not gated on `SOFT_DELETE`, so rows trashed while it was enabled stay recoverable, but only soft-deleted charts can be restored — permanently deleted charts are unrecoverable; returns `permission_denied` if the caller can see the chart but isn't an editor of it, or `NotFound` if the chart is outside the caller's RBAC visibility entirely |
### Dashboards
@@ -292,6 +293,7 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
| `generate_dashboard` | Create a new dashboard with specified charts |
| `add_chart_to_existing_dashboard` | Add a chart to an existing dashboard |
| `delete_dashboard` | Delete a dashboard by ID, UUID, or slug; leaves its charts intact (soft-deletes to trash when `SOFT_DELETE` is enabled; fails if alerts/reports are still attached, checked before editorship; otherwise returns `permission_denied` if the caller isn't an editor of the dashboard — owners, Admins, and explicitly granted editors qualify) |
| `restore_dashboard` | Restore a soft-deleted dashboard from trash by ID or UUID (slug lookup does not cover trashed dashboards); not gated on `SOFT_DELETE`, so rows trashed while it was enabled stay recoverable, but only soft-deleted dashboards can be restored — permanently deleted dashboards are unrecoverable; returns `permission_denied` if the caller can see the dashboard but isn't an editor of it, or `NotFound` if the dashboard is outside the caller's RBAC visibility entirely |
### SQL

View File

@@ -65,6 +65,9 @@ type, a first save can carry more of these automatic rewrites. It
happens at most once per chart per upgrade, and later saves record only
what the user changed.
The open history panel refreshes after a successful save; you do not need to
close and reopen it to see the new version.
## Previewing a version
Selecting a version loads it into the page so you can see it before deciding
@@ -111,6 +114,10 @@ and would overwrite the restore on the next sync.
## Opening a version as something new
Use the **… (actions)** menu on a save-group header to open that version as
a new chart or dashboard. Individual field-change rows preview the version;
they do not have separate action menus.
**Open as new** creates a brand-new chart or dashboard from the selected
version and leaves the original untouched. This is the safer option when you
want an old state back but are not certain you want to give up the current one.

View File

@@ -62,7 +62,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.2",
"antd": "^6.6.4",
"baseline-browser-mapping": "^2.11.23",
"baseline-browser-mapping": "^2.11.25",
"caniuse-lite": "^1.0.30001810",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
@@ -78,7 +78,7 @@
"remark-import-partial": "^0.0.2",
"reselect": "^5.3.0",
"storybook": "^10.6.0",
"swagger-ui-react": "^5.32.15",
"swagger-ui-react": "^5.33.0",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"unist-util-visit": "^5.1.0"
@@ -89,7 +89,7 @@
"@types/react": "^19.1.8",
"oxfmt": "^0.68.0",
"oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001",
"oxlint-tsgolint": "^7.0.2002",
"typescript": "7.0.2",
"webpack": "^5.111.0"
},

View File

@@ -1457,6 +1457,7 @@
"Open Source"
],
"pypi_packages": ["pinotdb"],
"version_requirements": "The Pinot extra requires pinotdb[sqlalchemy]>=8.0.0,<10.0.0. Earlier releases declare SQLAlchemy below 2 in their SQLAlchemy extra.",
"connection_string": "pinot+http://{broker_host}:{broker_port}/query?controller=http://{controller_host}:{controller_port}/",
"default_port": 8099,
"connection_examples": [

View File

@@ -18411,6 +18411,9 @@
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
},
"404": {
"$ref": "#/components/responses/404"
},
@@ -18435,6 +18438,79 @@
]
}
},
"/api/v1/dashboard/export_xlsx/download/{job_id}/": {
"get": {
"description": "Intentionally requires no login: the unguessable job_id, emailed only to the original requester (or handed to their own session via export_xlsx_status), is the credential. The dashboard access check already ran once, when the export was requested -- see security_manager.raise_for_access in export_xlsx. The file streams through Superset with the deployment's own storage credentials instead of redirecting to a signed storage URL, so it works for ambient identities that cannot sign (e.g. workload identity federation) and never mints a bearer URL Superset cannot observe or revoke.",
"parameters": [
{
"description": "The job_id from the export_xlsx response",
"in": "path",
"name": "job_id",
"required": true,
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The .xlsx file as an attachment"
},
"410": {
"description": "The link is unknown, expired, or the export failed"
},
"501": {
"description": "Excel export is not configured on this server"
}
},
"security": [],
"summary": "Download a completed dashboard Excel export",
"tags": [
"Dashboards"
]
}
},
"/api/v1/dashboard/export_xlsx/status/{job_id}/": {
"get": {
"description": "For a session with no email address to be notified at (e.g. an embedded/guest session), the frontend polls this endpoint with the job_id from the export_xlsx response instead of waiting for an email. Behind the same @protect() as the export request itself, unlike the login-free download_xlsx stream (which also has to work when clicked from a plain email link, possibly with no active session at all).",
"parameters": [
{
"description": "The job_id from the export_xlsx response",
"in": "path",
"name": "job_id",
"required": true,
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Job status: {\"status\": \"pending\"} while queued, {\"status\": \"running\"} once a worker has started executing, {\"status\": \"ready\", \"download_url\": \"...\"} once the file is available, or {\"status\": \"error\", \"message\": \"...\"} if the export failed."
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
}
},
"security": [
{
"jwt": []
},
{
"api_key": []
}
],
"summary": "Poll the status of a dashboard Excel export job",
"tags": [
"Dashboards"
]
}
},
"/api/v1/dashboard/favorite_status/": {
"get": {
"parameters": [
@@ -19693,7 +19769,7 @@
},
"/api/v1/dashboard/{pk}/export_xlsx/": {
"post": {
"description": "Enqueues an async task that writes each chart's data to its own worksheet, uploads the .xlsx to S3, and emails the requesting user a pre-signed download link. Returns immediately with a job id.",
"description": "Enqueues an async task that writes each chart's data to its own worksheet, uploads the .xlsx to the configured export storage, and records a download link. The requesting user is emailed the link when they have an address on file; either way the returned job id can be polled at export_xlsx/status/<job_id>/ for status and, once ready, the download link.",
"parameters": [
{
"description": "The dashboard id",

File diff suppressed because it is too large Load Diff

View File

@@ -191,10 +191,18 @@ elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
exasol = ["sqlalchemy-exasol>=6.0.0, <8.0"]
excel = ["xlrd>=2.0.2, <2.1"]
# Async dashboard "Export Data/Images to Excel": uploads the workbook to S3 and
# emails a pre-signed link. boto3 is imported lazily by superset.utils.s3, so
# installing this extra is only required to actually run exports.
# S3 storage backend for the async dashboard "Export Data/Images to Excel"
# feature (EXPORT_STORAGE["backend"] = S3ExportStorage()). boto3 is
# imported lazily by superset.utils.s3, so installing this extra is only
# required to actually run exports.
excel-export = ["boto3"]
# Google Cloud Storage backend for the same feature (EXPORT_STORAGE
# ["backend"] = GCSExportStorage()), for a deployment whose export bucket is a
# native GCS bucket rather than S3. google-cloud-storage is imported lazily by
# superset.utils.gcs, so this extra is an alternative to excel-export, not an
# addition to it -- pick whichever matches your bucket's provider.
# >=1.37 for ``Blob.open``, which superset.utils.gcs streams downloads through.
excel-export-gcs = ["google-cloud-storage>=1.37"]
fastmcp = [
"fastmcp>=3.4.7,<4.0",
# fastmcp 3.x itself requires mcp<2.0; the upper bound here was looser
@@ -248,7 +256,7 @@ ocient = [
]
oracle = ["oracledb>=4.0.2, <5"]
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
pinot = ["pinotdb[sqlalchemy]>=8.0.0, <10.0.0"]
playwright = ["playwright>=1.62.0, <2"]
postgres = ["psycopg2-binary==2.9.13"]
presto = ["pyhive[presto]>=0.7.0"]
@@ -390,6 +398,17 @@ module = [
]
warn_unused_ignores = false
# The mirror case: superset-core declares SqlaTable's id/uuid/perm as plain
# value types, so column expressions like SqlaTable.uuid.in_() only need a
# type: ignore when superset-core's sources are part of the same mypy run
# (full-repo lint) and not when only changed files are checked.
[[tool.mypy.overrides]]
module = [
"superset.mcp_service.dataset_scope",
"tests.unit_tests.mcp_service.dataset.tool.test_dataset_tools",
]
warn_unused_ignores = false
[tool.ruff]
# Exclude a variety of commonly ignored directories.
exclude = [
@@ -447,6 +466,9 @@ select = [
"F",
"G",
"I",
# f-strings passed to gettext are never translatable, and on Python
# 3.12+ babel extracts bogus msgids from their replacement fields.
"INT001",
"N",
"PT",
"Q",

View File

@@ -47,13 +47,13 @@ from __future__ import annotations
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
from babel.messages.pofile import read_po
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
BABEL_CFG = ROOT_DIR / "superset" / "translations" / "babel.cfg"
DEFAULT_POT = ROOT_DIR / "superset" / "translations" / "messages.pot"
# Kept in sync with the `pybabel extract` invocation in babel_update.sh.
@@ -99,16 +99,77 @@ def _msgid_set(pot_path: Path) -> set[MsgId]:
}
def extract_fresh(output_path: Path) -> None:
"""Run the project's extraction command into ``output_path``."""
subprocess.run( # noqa: S603
["pybabel", "extract", "-F", str(BABEL_CFG), "-o", str(output_path)]
+ EXTRACT_FLAGS,
def _archive_ref() -> str:
"""Return a tree-ish for ``git archive`` that matches the working tree.
``git stash create`` builds a commit object for the current index and
tracked-file modifications without touching the working tree, any ref,
or the actual stash, so it is safe to call while other processes are
using this checkout. It prints nothing when there is nothing to stash,
so fall back to ``HEAD``.
Deliberately uses ``Popen`` rather than ``run``: this module's tests
patch ``subprocess.run`` to fake the single "run pybabel" call, and a
second real ``run`` call here would be caught by that same patch.
"""
proc = subprocess.Popen( # noqa: S603
["git", "stash", "create"], # noqa: S607
cwd=ROOT_DIR,
check=True,
capture_output=True,
stdout=subprocess.PIPE,
text=True,
)
stash_sha, _ = proc.communicate()
if proc.returncode != 0:
raise subprocess.CalledProcessError(proc.returncode, proc.args)
return stash_sha.strip() or "HEAD"
def extract_fresh(output_path: Path) -> None:
"""Run the project's extraction command into ``output_path``.
Extracts from a ``git archive`` snapshot rather than the live checkout.
The Python-Unit job runs ``pytest -n auto --dist loadfile``, so many
worker processes share this checkout while this test runs; ``pybabel
extract`` walks everything under ``cwd``, so scanning the working
directory directly makes the msgid set depend on whatever transient
files another worker's test happens to write into the tree at that
instant.
"""
with tempfile.TemporaryDirectory() as snapshot_dir_str:
snapshot_dir = Path(snapshot_dir_str)
archive = subprocess.Popen( # noqa: S603
["git", "archive", _archive_ref()], # noqa: S607
cwd=ROOT_DIR,
stdout=subprocess.PIPE,
)
assert archive.stdout is not None
with tarfile.open(fileobj=archive.stdout, mode="r|") as tar:
# Use the safe extraction filter (PEP 706) where available; older
# Python patch releases without the backport still work, just
# without that defense-in-depth (this is our own trusted `git
# archive` output, not attacker-controlled).
data_filter = getattr(tarfile, "data_filter", None)
if data_filter is not None:
tar.extraction_filter = data_filter
tar.extractall(snapshot_dir) # noqa: S202 (own trusted `git archive` output)
if archive.wait() != 0:
raise subprocess.CalledProcessError(archive.returncode, archive.args)
subprocess.run( # noqa: S603
[
"pybabel",
"extract",
"-F",
str(snapshot_dir / "superset" / "translations" / "babel.cfg"),
"-o",
str(output_path),
]
+ EXTRACT_FLAGS,
cwd=snapshot_dir,
check=True,
capture_output=True,
text=True,
)
def diff(committed_pot: Path = DEFAULT_POT) -> tuple[set[MsgId], set[MsgId]]:

View File

@@ -17,47 +17,47 @@
"@babel/core": "^8.0.5",
"@babel/preset-env": "^8.0.5",
"@babel/preset-typescript": "^8.0.1",
"@types/node": "^26.5.1",
"@types/node": "^26.6.1",
"babel-loader": "^10.1.1",
"jsdom": "^30.0.1",
"jsdom": "^30.1.0",
"tscw-config": "^1.1.2",
"typescript": "^7.0.2",
"vitest": "^5.0.0",
"vitest": "^5.0.1",
"webpack": "^5.111.0",
"webpack-cli": "^7.2.3"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.3.0",
"@csstools/css-color-parser": "^4.1.10",
"@csstools/css-color-parser": "^4.2.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
"version": "9.1.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
"integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.3",
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@babel/cli": {
@@ -1648,9 +1648,9 @@
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
"dev": true,
"funding": [
{
@@ -1672,9 +1672,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz",
"integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
"dev": true,
"funding": [
{
@@ -1689,7 +1689,7 @@
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.3.0"
"@csstools/css-calc": "^3.4.0"
},
"engines": {
"node": ">=20.19.0"
@@ -1723,9 +1723,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz",
"integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
@@ -1846,9 +1846,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
"integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -1857,9 +1857,9 @@
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
"integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
"cpu": [
"arm"
],
@@ -1875,9 +1875,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz",
"integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
"cpu": [
"arm64"
],
@@ -1893,9 +1893,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz",
"integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
"cpu": [
"arm64"
],
@@ -1911,9 +1911,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz",
"integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
"cpu": [
"x64"
],
@@ -1929,9 +1929,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz",
"integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
"cpu": [
"x64"
],
@@ -1947,9 +1947,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz",
"integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
"cpu": [
"arm"
],
@@ -1965,9 +1965,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz",
"integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
"cpu": [
"arm64"
],
@@ -1986,9 +1986,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz",
"integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
"cpu": [
"arm64"
],
@@ -2007,9 +2007,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz",
"integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
"cpu": [
"ppc64"
],
@@ -2028,9 +2028,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz",
"integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
"cpu": [
"s390x"
],
@@ -2049,9 +2049,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz",
"integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
"cpu": [
"x64"
],
@@ -2070,9 +2070,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz",
"integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
"cpu": [
"x64"
],
@@ -2091,9 +2091,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz",
"integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
"cpu": [
"arm64"
],
@@ -2109,9 +2109,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz",
"integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
"cpu": [
"arm64"
],
@@ -2127,9 +2127,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz",
"integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
"cpu": [
"x64"
],
@@ -2205,9 +2205,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.5.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
"version": "26.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
"integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2555,14 +2555,14 @@
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
"integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"@vitest/spy": "5.0.1",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
@@ -2583,9 +2583,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
"integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2905,9 +2905,9 @@
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3557,16 +3557,16 @@
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.1.2",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
@@ -3576,13 +3576,12 @@
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"undici": "^8.10.2",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
@@ -3968,13 +3967,13 @@
}
},
"node_modules/magic-string": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
"integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/mdn-data": {
@@ -4089,9 +4088,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true,
"funding": [
{
@@ -4501,14 +4500,14 @@
}
},
"node_modules/rolldown": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
"integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.148.0",
"@oxc-project/types": "=0.149.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -4518,21 +4517,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.7",
"@rolldown/binding-android-arm64": "1.2.7",
"@rolldown/binding-darwin-arm64": "1.2.7",
"@rolldown/binding-darwin-x64": "1.2.7",
"@rolldown/binding-freebsd-x64": "1.2.7",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.7",
"@rolldown/binding-linux-arm64-gnu": "1.2.7",
"@rolldown/binding-linux-arm64-musl": "1.2.7",
"@rolldown/binding-linux-ppc64-gnu": "1.2.7",
"@rolldown/binding-linux-s390x-gnu": "1.2.7",
"@rolldown/binding-linux-x64-gnu": "1.2.7",
"@rolldown/binding-linux-x64-musl": "1.2.7",
"@rolldown/binding-openharmony-arm64": "1.2.7",
"@rolldown/binding-win32-arm64-msvc": "1.2.7",
"@rolldown/binding-win32-x64-msvc": "1.2.7"
"@rolldown/binding-android-arm-eabi": "1.2.8",
"@rolldown/binding-android-arm64": "1.2.8",
"@rolldown/binding-darwin-arm64": "1.2.8",
"@rolldown/binding-darwin-x64": "1.2.8",
"@rolldown/binding-freebsd-x64": "1.2.8",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
"@rolldown/binding-linux-arm64-musl": "1.2.8",
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
"@rolldown/binding-linux-x64-gnu": "1.2.8",
"@rolldown/binding-linux-x64-musl": "1.2.8",
"@rolldown/binding-openharmony-arm64": "1.2.8",
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
"@rolldown/binding-win32-x64-msvc": "1.2.8"
}
},
"node_modules/saxes": {
@@ -4724,13 +4723,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
@@ -4906,9 +4898,9 @@
}
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5011,17 +5003,17 @@
}
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"picomatch": "^4.0.7",
"postcss": "^8.5.28",
"rolldown": "~1.2.6",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -5038,7 +5030,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -5090,14 +5082,14 @@
}
},
"node_modules/vitest": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"@vitest/mocker": "5.0.1",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
@@ -5123,12 +5115,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-playwright": "5.0.1",
"@vitest/browser-preview": "5.0.1",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"@vitest/coverage-istanbul": "5.0.1",
"@vitest/coverage-v8": "5.0.1",
"@vitest/ui": "5.0.1",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
@@ -5337,9 +5329,9 @@
}
},
"node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"version": "17.1.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -41,12 +41,12 @@
"@babel/core": "^8.0.5",
"@babel/preset-env": "^8.0.5",
"@babel/preset-typescript": "^8.0.1",
"@types/node": "^26.5.1",
"@types/node": "^26.6.1",
"babel-loader": "^10.1.1",
"jsdom": "^30.0.1",
"jsdom": "^30.1.0",
"tscw-config": "^1.1.2",
"typescript": "^7.0.2",
"vitest": "^5.0.0",
"vitest": "^5.0.1",
"webpack": "^5.111.0",
"webpack-cli": "^7.2.3"
},

View File

@@ -1,614 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { waitForChartLoad } from 'cypress/utils';
import { SUPPORTED_CHARTS_DASHBOARD } from 'cypress/utils/urls';
import {
openTopLevelTab,
SUPPORTED_TIER1_CHARTS,
SUPPORTED_TIER2_CHARTS,
} from './utils';
function interceptSamples() {
cy.intercept(`**/datasource/samples*`).as('samples');
}
function openModalFromMenu(chartType: string) {
interceptSamples();
cy.get(
`[data-test-viz-type='${chartType}'] [aria-label='More Options']`,
).click();
cy.get('.ant-dropdown')
.not('.ant-dropdown-hidden')
.find("[role='menu'] [role='menuitem']")
.eq(5)
.should('contain', 'Drill to detail')
.click();
cy.wait('@samples');
}
function drillToDetail(targetMenuItem: string) {
interceptSamples();
cy.get('.ant-dropdown')
.not('.ant-dropdown-hidden')
.first()
.find("[role='menu'] [role='menuitem']")
.contains(new RegExp(`^${targetMenuItem}$`))
.first()
.trigger('keydown', { keyCode: 13, which: 13, force: true });
cy.getBySel('metadata-bar').should('be.visible');
cy.wait('@samples');
}
const drillToDetailBy = (targetDrill: string) => {
interceptSamples();
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)')
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill to detail by$/)
.trigger('mouseover', { force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]',
)
.should('be.visible')
.find('[role="menuitem"]')
.then($el => {
cy.wrap($el)
.contains(new RegExp(`^${targetDrill}$`))
.trigger('keydown', { keyCode: 13, which: 13, force: true });
});
cy.getBySel('metadata-bar').should('be.visible');
return cy.wait('@samples');
};
function closeModal() {
cy.get('body').then($body => {
if ($body.find('[data-test="close-drilltodetail-modal"]').length) {
cy.getBySel('close-drilltodetail-modal').click({ force: true });
}
});
}
function testTimeChart(vizType: string) {
interceptSamples();
cy.get(`[data-test-viz-type='${vizType}'] canvas`).then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mousemove', 85, 93);
cy.wrap($canvas).rightclick(85, 93);
drillToDetailBy('Drill to detail by 1965');
cy.getBySel('filter-val').should('contain', '1965');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mousemove', 85, 93);
cy.wrap($canvas).rightclick(85, 93);
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mousemove', 85, 93);
cy.wrap($canvas).rightclick(85, 93);
drillToDetailBy('Drill to detail by all');
cy.getBySel('filter-val').first().should('contain', '1965');
cy.getBySel('filter-val').eq(1).should('contain', 'boy');
closeModal();
});
}
// TODO fix this test, it has issues with autoscrolling and the locked title
// flakes intricately when the righClick is obstructed by the title.
// Tried many option around scrollIntoView, force, etc. but no luck.
describe.skip('Drill to detail modal', () => {
beforeEach(() => {
closeModal();
});
describe('Tier 1 charts', () => {
before(() => {
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
describe('Modal actions', () => {
it('opens the modal from the context menu', () => {
openModalFromMenu('big_number_total');
cy.get("[role='dialog'] .draggable-trigger").should(
'contain',
'Drill to detail: Big Number',
);
});
it('refreshes the data', () => {
openModalFromMenu('big_number_total');
// move to the last page
cy.get('.ant-pagination-item').eq(5).click();
// skips error on pagination
cy.on('uncaught:exception', () => false);
cy.wait('@samples');
// reload
cy.get("[aria-label='Reload']").click();
cy.wait('@samples');
// make sure it started back from first page
cy.get('.ant-pagination-item-active').should('contain', '1');
});
it('paginates', () => {
openModalFromMenu('big_number_total');
// checking the data
cy.getBySel('row-count-label').should('contain', '75.7k rows');
cy.get('.virtual-table-cell').should($rows => {
expect($rows).to.contain('Amy');
});
// checking the paginated data
cy.get('.ant-pagination-item')
.should('have.length', 6)
.should($pages => {
expect($pages).to.contain('1');
expect($pages).to.contain('1514');
});
cy.get('.ant-pagination-item').eq(4).click();
// skips error on pagination
cy.on('uncaught:exception', () => false);
cy.wait('@samples');
cy.get('.virtual-table-cell').should($rows => {
expect($rows).to.contain('Kimberly');
});
// verify scroll top on pagination
cy.getBySelLike('Number-modal').find('.virtual-grid').scrollTo(0, 200);
cy.get('.virtual-grid').contains('Kim').should('not.be.visible');
cy.get('.ant-pagination-item').eq(0).click();
cy.get('.virtual-grid').contains('Aaron').should('be.visible');
});
});
describe('Big number total', () => {
it('opens the modal with no filters', () => {
interceptSamples();
// opens the modal by clicking on the number on the chart
cy.get(
"[data-test-viz-type='big_number_total'] .header-line",
).scrollIntoView();
cy.get(
"[data-test-viz-type='big_number_total'] .header-line",
).rightclick();
drillToDetail('Drill to detail');
cy.getBySel('filter-val').should('not.exist');
});
});
describe('Big number with trendline', () => {
it('opens the modal with the correct data', () => {
interceptSamples();
// opens the modal by clicking on the number
cy.get(
"[data-test-viz-type='big_number'] .header-line",
).scrollIntoView();
cy.get("[data-test-viz-type='big_number'] .header-line").rightclick();
drillToDetail('Drill to detail');
cy.getBySel('filter-val').should('not.exist');
closeModal();
// opens the modal by clicking on the trendline
cy.get("[data-test-viz-type='big_number'] canvas").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mousemove', 1, 14);
cy.wrap($canvas).rightclick(1, 14);
drillToDetailBy('Drill to detail by 1965');
// checking the filter
cy.getBySel('filter-val').should('contain', '1965');
});
});
});
describe('Table', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
// focus on table first to trigger browser scroll
cy.get("[data-test-viz-type='table']").contains('boy').rightclick();
cy.wait(500);
cy.get("[data-test-viz-type='table']").contains('boy').scrollIntoView();
cy.get("[data-test-viz-type='table']").contains('boy').rightclick();
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
// focus on table first to trigger browser scroll
cy.get("[data-test-viz-type='table']").contains('girl').rightclick();
cy.wait(500);
cy.get("[data-test-viz-type='table']").scrollIntoView();
cy.get("[data-test-viz-type='table']").contains('girl').rightclick();
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
});
});
describe('Pivot Table V2', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
cy.get("[data-test-viz-type='pivot_table_v2']")
.find('[role="gridcell"]')
.first()
.rightclick();
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
cy.get("[data-test-viz-type='pivot_table_v2']")
.find('[role="gridcell"]')
.first()
.rightclick();
drillToDetailBy('Drill to detail by CA');
cy.getBySel('filter-val').should('contain', 'CA');
closeModal();
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
cy.get("[data-test-viz-type='pivot_table_v2']")
.find('[role="gridcell"]')
.eq(3)
.rightclick();
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
closeModal();
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
cy.get("[data-test-viz-type='pivot_table_v2']")
.find('[role="gridcell"]')
.eq(3)
.rightclick();
drillToDetailBy('Drill to detail by FL');
cy.getBySel('filter-val').should('contain', 'FL');
closeModal();
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
cy.get("[data-test-viz-type='pivot_table_v2']")
.find('[role="gridcell"]')
.eq(3)
.rightclick();
drillToDetailBy('Drill to detail by all');
cy.getBySel('filter-val').first().should('contain', 'girl');
cy.getBySel('filter-val').eq(1).should('contain', 'FL');
});
});
describe('Line Chart', () => {
it('opens the modal with the correct filters', () => {
testTimeChart('echarts_timeseries_line');
});
});
describe.skip('Bar Chart', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
cy.get("[data-test-viz-type='echarts_timeseries_bar'] canvas").then(
$canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(70, 100);
drillToDetailBy('Drill to detail by 1965');
cy.getBySel('filter-val').should('contain', '1965');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(70, 100);
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(70, 100);
drillToDetailBy('Drill to detail by all');
cy.getBySel('filter-val').first().should('contain', '1965');
cy.getBySel('filter-val').eq(1).should('contain', 'boy');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(72, 200);
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
},
);
});
});
describe.skip('Area Chart', () => {
it('opens the modal with the correct filters', () => {
testTimeChart('echarts_area');
});
});
describe('Scatter Chart', () => {
it('opens the modal with the correct filters', () => {
testTimeChart('echarts_timeseries_scatter');
});
});
describe('Pie', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
// opens the modal by clicking on the slice of the Pie chart
cy.get("[data-test-viz-type='pie'] canvas").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(130, 150);
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(230, 190);
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
});
});
});
describe.skip('World Map', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
cy.get("[data-test-viz-type='world_map'] svg").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(70, 150);
drillToDetailBy('Drill to detail by USA');
cy.getBySel('filter-val').should('contain', 'USA');
closeModal();
});
cy.get("[data-test-viz-type='world_map'] svg").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(200, 140);
drillToDetailBy('Drill to detail by SRB');
cy.getBySel('filter-val').should('contain', 'SRB');
});
});
});
});
describe('Tier 2 charts', () => {
before(() => {
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
openTopLevelTab('Tier 2');
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
});
describe('Modal actions', () => {
it('clears filters', () => {
interceptSamples();
// opens the modal by clicking on the box on the chart
cy.get("[data-test-viz-type='box_plot'] canvas").then($canvas => {
const canvasWidth = $canvas.width() || 0;
const canvasHeight = $canvas.height() || 0;
const canvasCenterX = canvasWidth / 3 + 15;
const canvasCenterY = (canvasHeight * 5) / 6;
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(canvasCenterX, canvasCenterY, {
force: true,
});
drillToDetailBy('Drill to detail by boy');
// checking the filter
cy.getBySel('filter-val').should('contain', 'boy');
cy.getBySel('row-count-label').should('contain', '39.2k rows');
cy.get('.ant-pagination-item')
.should('have.length', 6)
.then($pages => {
expect($pages).to.contain('1');
expect($pages).to.contain('785');
});
// close the filter and test that data was reloaded
cy.getBySel('filter-col').find("[aria-label='Close']").click();
cy.wait('@samples');
cy.getBySel('row-count-label').should('contain', '75.7k rows');
cy.get('.ant-pagination-item-active').should('contain', '1');
cy.get('.ant-pagination-item')
.should('have.length', 6)
.then($pages => {
expect($pages).to.contain('1');
expect($pages).to.contain('1514');
});
});
});
});
describe('Box plot', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
cy.get("[data-test-viz-type='box_plot'] canvas").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mousemove', 135, 275);
cy.wrap($canvas).rightclick(135, 275);
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mousemove', 270, 280);
cy.wrap($canvas).rightclick(270, 280);
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
});
});
});
describe('Generic Chart', () => {
it('opens the modal with the correct filters', () => {
testTimeChart('echarts_timeseries');
});
});
describe('Smooth Chart', () => {
it('opens the modal with the correct filters', () => {
testTimeChart('echarts_timeseries_smooth');
});
});
describe('Step Line Chart', () => {
it('opens the modal with the correct filters', () => {
testTimeChart('echarts_timeseries_step');
});
});
describe('Funnel Chart', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
cy.get("[data-test-viz-type='funnel'] canvas").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(170, 90);
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(190, 250);
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
});
});
});
describe('Gauge Chart', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
cy.get("[data-test-viz-type='gauge_chart'] canvas").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(135, 95);
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(95, 135);
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
});
});
});
describe('Mixed Chart', () => {
it('opens the modal with the correct filters', () => {
testTimeChart('mixed_timeseries');
});
});
describe.skip('Radar Chart', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
cy.get("[data-test-viz-type='radar'] canvas").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(180, 45);
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(180, 85);
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
});
});
});
describe('Treemap', () => {
it('opens the modal with the correct filters', () => {
interceptSamples();
cy.get("[data-test-viz-type='treemap_v2'] canvas").then($canvas => {
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(100, 30);
drillToDetailBy('Drill to detail by boy');
cy.getBySel('filter-val').should('contain', 'boy');
closeModal();
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).rightclick(150, 250);
drillToDetailBy('Drill to detail by girl');
cy.getBySel('filter-val').should('contain', 'girl');
});
});
});
});
});

View File

@@ -1,201 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
import { interceptCharts, interceptDatasets, interceptGet } from './utils';
export const SAMPLE_CHART = { name: 'Most Populated Countries', viz: 'table' };
/**
* Look up a dataset ID by table name
*/
export function getDatasetId(tableName: string): Cypress.Chainable<number> {
return cy
.request({
method: 'GET',
url: `/api/v1/dataset/?q=${encodeURIComponent(
JSON.stringify({
filters: [{ col: 'table_name', opr: 'eq', value: tableName }],
}),
)}`,
})
.then(response => {
const datasets = response.body.result;
if (datasets && datasets.length > 0) {
return datasets[0].id;
}
throw new Error(`Dataset with table name "${tableName}" not found`);
});
}
/**
* Look up a chart ID by slice name
*/
export function getChartId(sliceName: string): Cypress.Chainable<number> {
return cy
.request({
method: 'GET',
url: `/api/v1/chart/?q=${encodeURIComponent(
JSON.stringify({
filters: [{ col: 'slice_name', opr: 'eq', value: sliceName }],
}),
)}`,
})
.then(response => {
const charts = response.body.result;
if (charts && charts.length > 0) {
return charts[0].id;
}
throw new Error(`Chart with slice name "${sliceName}" not found`);
});
}
export function visitDashboard(createSample = true) {
interceptCharts();
interceptGet();
interceptDatasets();
if (createSample) {
cy.createSampleDashboards([0]);
}
cy.visit(SAMPLE_DASHBOARD_1);
cy.wait('@get');
cy.wait('@getCharts');
cy.wait('@getDatasets');
cy.url().should('contain', 'native_filters_key');
}
export function prepareDashboardFilters(
filters: { name: string; column: string; datasetId?: number }[],
) {
cy.createSampleDashboards([0]);
// First, look up the dataset ID and chart ID dynamically
getDatasetId('wb_health_population').then(datasetId => {
getChartId('Most Populated Countries').then(chartId => {
cy.request({
method: 'GET',
url: `api/v1/dashboard/1-sample-dashboard`,
}).then(res => {
const { body } = res;
const dashboardId = body.result.id;
const allFilters: Record<string, unknown>[] = [];
filters.forEach((f, i) => {
allFilters.push({
id: `NATIVE_FILTER-fLH0pxFQ${i}`,
controlValues: {
enableEmptyFilter: false,
defaultToFirstItem: false,
creatable: true,
multiSelect: true,
searchAllOptions: false,
inverseSelection: false,
},
name: f.name,
filterType: 'filter_select',
targets: [
{
datasetId: f.datasetId ?? datasetId,
column: { name: f.column },
},
],
defaultDataMask: {
extraFormData: {},
filterState: {},
ownState: {},
},
cascadeParentIds: [],
scope: {
rootPath: ['ROOT_ID'],
excluded: [],
},
type: 'NATIVE_FILTER',
description: '',
chartsInScope: [chartId],
tabsInScope: [],
});
});
if (dashboardId) {
const jsonMetadata = {
native_filter_configuration: allFilters,
chart_customization_config: [],
timed_refresh_immune_slices: [],
expanded_slices: {},
expand_all_slices: false,
refresh_frequency: 0,
color_scheme: '',
label_colors: {},
shared_label_colors: [],
color_scheme_domain: [],
cross_filters_enabled: false,
positions: {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-0rHnUz4nMA'],
parents: ['ROOT_ID'],
},
HEADER_ID: {
id: 'HEADER_ID',
type: 'HEADER',
meta: { text: '1 - Sample dashboard' },
},
'CHART-DF6EfI55F-': {
type: 'CHART',
id: 'CHART-DF6EfI55F-',
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-0rHnUz4nMA'],
meta: {
width: 4,
height: 50,
chartId,
sliceName: 'Most Populated Countries',
},
},
'ROW-0rHnUz4nMA': {
type: 'ROW',
id: 'ROW-0rHnUz4nMA',
children: ['CHART-DF6EfI55F-'],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
},
default_filters: '{}',
filter_scopes: {},
chart_configuration: {},
};
return cy
.request({
method: 'PUT',
url: `api/v1/dashboard/${dashboardId}`,
body: {
json_metadata: JSON.stringify(jsonMetadata),
},
})
.then(() => visitDashboard(false));
}
return cy;
});
});
});
}

View File

@@ -1,559 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { dashboardView, nativeFilters } from 'cypress/support/directories';
import {
ChartSpec,
setSelectSearchInput,
waitForChartLoad,
} from 'cypress/utils';
export const WORLD_HEALTH_CHARTS = [
{ name: '% Rural', viz: 'world_map' },
{ name: 'Most Populated Countries', viz: 'table' },
{ name: "World's Population", viz: 'big_number' },
{ name: 'Growth Rate', viz: 'echarts_timeseries_line' },
{ name: 'Rural Breakdown', viz: 'sunburst_v2' },
{ name: "World's Pop Growth", viz: 'echarts_area' },
{ name: 'Life Expectancy VS Rural %', viz: 'bubble_v2' },
{ name: 'Treemap', viz: 'treemap_v2' },
{ name: 'Box plot', viz: 'box_plot' },
] as ChartSpec[];
export const SUPPORTED_TIER1_CHARTS = [
{ name: 'Big Number', viz: 'big_number_total' },
{ name: 'Big Number with Trendline', viz: 'big_number' },
{ name: 'Pie Chart', viz: 'pie' },
{ name: 'Table', viz: 'table' },
{ name: 'Pivot Table', viz: 'pivot_table_v2' },
{ name: 'Line Chart', viz: 'echarts_timeseries_line' },
{ name: 'Area Chart', viz: 'echarts_area' },
{ name: 'Scatter Chart', viz: 'echarts_timeseries_scatter' },
{ name: 'Bar Chart', viz: 'echarts_timeseries_bar' },
] as ChartSpec[];
export const SUPPORTED_TIER2_CHARTS = [
{ name: 'Box Plot Chart', viz: 'box_plot' },
{ name: 'Generic Chart', viz: 'echarts_timeseries' },
{ name: 'Smooth Line Chart', viz: 'echarts_timeseries_smooth' },
{ name: 'Step Line Chart', viz: 'echarts_timeseries_step' },
{ name: 'Funnel Chart', viz: 'funnel' },
{ name: 'Gauge Chart', viz: 'gauge_chart' },
{ name: 'Radar Chart', viz: 'radar' },
{ name: 'Treemap V2 Chart', viz: 'treemap_v2' },
{ name: 'Mixed Chart', viz: 'mixed_timeseries' },
] as ChartSpec[];
export const testItems = {
dashboard: 'Cypress test Dashboard',
dataset: 'Vehicle Sales',
datasetForNativeFilter: 'wb_health_population',
chart: 'Cypress chart',
newChart: 'New Cypress Chart',
createdDashboard: 'New Dashboard',
defaultNameDashboard: '[ untitled dashboard ]',
newDashboardTitle: `Test dashboard [NEW TEST]`,
bulkFirstNameDashboard: 'First Dash',
bulkSecondNameDashboard: 'Second Dash',
worldBanksDataCopy: `World Bank's Data [copy]`,
filterType: {
value: 'Value',
numerical: 'Numerical range',
timeColumn: 'Time column',
timeGrain: 'Time grain',
timeRange: 'Time range',
},
topTenChart: {
name: 'Most Populated Countries',
filterColumn: 'country_name',
filterColumnYear: 'year',
filterColumnRegion: 'region',
filterColumnCountryCode: 'country_code',
},
filterDefaultValue: 'United States',
filterOtherCountry: 'China',
filterTimeGrain: 'Month',
filterTimeColumn: 'created',
filterNumericalColumn: 'SP_RUR_TOTL_ZS',
};
export const nativeFilterTooltips = {
searchAllFilterOptions:
'By default, each filter loads at most 1000 choices at the initial page load. Check this box if you have more than 1000 filter values and want to enable dynamically searching that loads filter values as users type (may add stress to your database).',
defaultToFirstItem: 'When using this option, default value can’t be set',
inverseSelection: 'Exclude selected values',
required: 'User must select a value before applying the filter',
multipleSelect: 'Allow selecting multiple values',
defaultValue:
'Default value must be set when "Filter value is required" is checked',
preFilter: `Add filter clauses to control the filter's source query, though only in the context of the autocomplete i.e., these conditions do not impact how the filter is applied to the dashboard. This is useful when you want to improve the query's performance by only scanning a subset of the underlying data or limit the available values displayed in the filter.`,
};
export const nativeFilterOptions = [
'Filter has default value',
'Multiple select',
'Filter value is required',
'Filter is hierarchical',
'Default to first item',
'Inverse selection',
'Search all filter options',
'Pre-filter available values',
'Sort filter values',
];
export const valueNativeFilterOptions = [
'Pre-filter available values',
'Sort filter values',
'Filter has default value',
'Select first filter value by default',
'Can select multiple values',
'Dynamically search all filter values',
'Inverse selection',
'Filter value is required',
];
export function interceptGet() {
cy.intercept('GET', '**/api/v1/dashboard/*').as('get');
}
export function interceptFiltering() {
cy.intercept('GET', `**/api/v1/dashboard/?q=*`).as('filtering');
}
export function interceptBulkDelete() {
cy.intercept('DELETE', `**/api/v1/dashboard/?q=*`).as('bulkDelete');
}
export function interceptDelete() {
cy.intercept('DELETE', `**/api/v1/dashboard/*`).as('delete');
}
export function interceptUpdate() {
cy.intercept('PUT', `**/api/v1/dashboard/*`).as('update');
}
export function interceptExploreUpdate() {
cy.intercept('PUT', `**/api/v1/chart/*`).as('chartUpdate');
}
export function interceptPost() {
cy.intercept('POST', `**/api/v1/dashboard/`).as('post');
}
export function interceptLog() {
cy.intercept('**/superset/log/?explode=events&dashboard_id=*').as('logs');
}
export function interceptDataset() {
cy.intercept('GET', `**/api/v1/dataset/*`).as('getDataset');
}
export function interceptCharts() {
cy.intercept('GET', `**/api/v1/dashboard/*/charts`).as('getCharts');
}
export function interceptDatasets() {
cy.intercept('GET', `**/api/v1/dashboard/*/datasets`).as('getDatasets');
}
export function interceptFilterState() {
cy.intercept('POST', `**/api/v1/dashboard/*/filter_state*`).as(
'postFilterState',
);
}
export function setFilter(filter: string, option: string) {
interceptFiltering();
cy.get(`[aria-label^="${filter}"]`).first().click();
cy.get(`.ant-select-item-option[title="${option}"]`).first().click({
force: true,
});
cy.wait('@filtering');
}
/** ************************************************************************
* Expend Native filter from the left panel on dashboard
* @returns {None}
* @summary helper for expend native filter
************************************************************************* */
export function expandFilterOnLeftPanel() {
return cy
.get(nativeFilters.filterFromDashboardView.expand)
.should('be.visible')
.click({ force: true });
}
/** ************************************************************************
* Collapses Native Filter from the left panel on dashboard
* @returns {None}
* @summary helper for collapse native filter
************************************************************************* */
export function collapseFilterOnLeftPanel() {
cy.get(nativeFilters.filterFromDashboardView.collapse)
.should('be.visible')
.click();
cy.get(nativeFilters.filterFromDashboardView.collapse).should(
'not.be.visible',
);
}
/** ************************************************************************
* Enter Native Filter edit modal from the left panel on dashboard
* @returns {None}
* @summary helper for enter native filter edit modal
************************************************************************* */
export function enterNativeFilterEditModal(waitForDataset = true) {
interceptDataset();
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
cy.get('.ant-dropdown-menu').should('be.visible');
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).click({
force: true,
});
cy.get(nativeFilters.modal.container).should('be.visible');
if (waitForDataset) {
cy.wait('@getDataset');
}
}
/** ************************************************************************
* Clicks on new filter button
* @returns {None}
* @summary helper for adding new filter
************************************************************************* */
export function clickOnAddFilterInModal() {
cy.get('[data-test="new-item-dropdown-button"]').trigger('mouseover');
cy.get('.ant-dropdown-menu').should('be.visible');
cy.contains('.ant-dropdown-menu-item', 'Add filter').click();
}
/** ************************************************************************
* Fills value native filter form with basic information
* @param {string} type type for filter: Value, Numerical range,Time column,Time grain,Time range
* @param {string} name name for filter
* @param {string} dataset which dataset should be used
* @param {string} filterColumn which column should be used
* @returns {None}
* @summary helper for filling value native filter form
************************************************************************* */
export function fillNativeFilterForm(
type: string,
name: string,
dataset?: string,
filterColumn?: string,
) {
cy.get(nativeFilters.filtersPanel.filterTypeInput).within(() => {
cy.get('input').then($input => {
setSelectSearchInput($input, type);
});
});
cy.get(nativeFilters.modal.container)
.find(nativeFilters.filtersPanel.filterName)
.last()
.click({ scrollBehavior: false });
cy.get(nativeFilters.modal.container)
.find(nativeFilters.filtersPanel.filterName)
.last()
.clear({ force: true });
cy.get(nativeFilters.modal.container)
.find(nativeFilters.filtersPanel.filterName)
.last()
.type(name, { scrollBehavior: false, force: true });
if (dataset) {
cy.get('div[aria-label="Dataset"]').within(() => {
cy.get('input').then($input => {
setSelectSearchInput($input, dataset, true);
});
});
cy.get(nativeFilters.silentLoading).should('not.exist');
}
if (filterColumn) {
cy.get('div[aria-label="Column select"]').within(() => {
cy.get('input').then($input => {
setSelectSearchInput($input, filterColumn, true);
});
});
}
}
/** ************************************************************************
* Get native filter placeholder e.g 9 options
* @param {number} index which input it fills
* @returns cy object for assertions
* @summary helper for getting placeholder value
************************************************************************* */
export function getNativeFilterPlaceholderWithIndex(index: number) {
return cy.get(nativeFilters.filtersPanel.columnEmptyInput).eq(index);
}
/** ************************************************************************
* Apply native filter value from dashboard view
* @param {number} index which input it fills
* @param {string} value what is filter value
* @returns {null}
* @summary put value to nth native filter input in view
************************************************************************* */
export function applyNativeFilterValueWithIndex(index: number, value: string) {
cy.get(nativeFilters.filterFromDashboardView.filterValueInput)
.eq(index)
.should('exist', { timeout: 10000 })
.type(`${value}{enter}`, { force: true });
// click the title to dismiss shown options
cy.get(nativeFilters.filterFromDashboardView.filterName)
.eq(index)
.click({ force: true });
}
/** ************************************************************************
* Fills parent filter input
* @param {number} index which input it fills
* @param {string} value on which filter it depends on
* @returns {null}
* @summary takes first or second input and modify the depends on filter value
************************************************************************* */
export function addParentFilterWithValue(index: number, value: string) {
return cy
.get(nativeFilters.filterConfigurationSections.displayedSection)
.within(() => {
cy.get('input[aria-label^="Limit type"]')
.eq(index)
.click({ force: true });
cy.get('input[aria-label^="Limit type"]')
.eq(index)
.type(`${value}{enter}`, { delay: 30, force: true });
});
}
/** ************************************************************************
* Save Native Filter Settings
* @returns {None}
* @summary helper for save native filters settings
************************************************************************* */
export function saveNativeFilterSettings(charts: ChartSpec[]) {
cy.get(nativeFilters.modal.footer)
.contains('Save')
.should('be.visible')
.click({ force: true });
// Wait for modal to either close or remain open
cy.get('body').should($body => {
const modalExists = $body.find(nativeFilters.modal.container).length > 0;
if (modalExists) {
cy.get(nativeFilters.modal.footer)
.contains('Save')
.should('be.visible')
.click({ force: true });
}
});
// Ensure modal is closed
cy.get(nativeFilters.modal.container).should('not.exist');
// Wait for all charts to load
charts.forEach(chart => {
waitForChartLoad(chart);
});
}
/** ************************************************************************
* Cancel Native filter settings
* @returns {None}
* @summary helper for cancel native filters settings
************************************************************************* */
export function cancelNativeFilterSettings() {
cy.get(nativeFilters.modal.footer)
.find(nativeFilters.modal.cancelButton)
.should('be.visible')
.click();
cy.get(nativeFilters.modal.alertXUnsavedFilters)
.should('be.visible')
.should('have.text', 'There are unsaved changes.');
cy.get(nativeFilters.modal.footer)
.find(nativeFilters.modal.confirmCancelButton)
.contains('cancel')
.click({ force: true });
cy.get(nativeFilters.modal.container).should('not.exist');
}
/** ************************************************************************
* Validate filter name on dashboard
* @param name: filter name to validate
* @return {null}
* @summary helper for validate filter name on dashboard
************************************************************************* */
export function validateFilterNameOnDashboard(name: string) {
cy.get(nativeFilters.filterFromDashboardView.filterName)
.should('be.visible', { timeout: 40000 })
.contains(`${name}`);
}
/** ************************************************************************
* Validate filter content on dashboard
* @param filterContent: filter content to validate
* @return {null}
* @summary helper for validate filter content on dashboard
************************************************************************* */
export function validateFilterContentOnDashboard(filterContent: string) {
cy.get(nativeFilters.filterFromDashboardView.filterContent)
.contains(`${filterContent}`)
.should('be.visible');
}
/** ************************************************************************
* Delete Native filter
* @return {null}
* @summary helper for delete native filter
************************************************************************* */
export function deleteNativeFilter() {
cy.get(nativeFilters.filtersList.removeIcon).first().click();
}
/** ************************************************************************
* Undo delete Native filter
* @return {null}
* @summary helper for undo delete native filter
************************************************************************ */
export function undoDeleteNativeFilter() {
deleteNativeFilter();
cy.contains('Undo?').click();
}
/** ************************************************************************
* Check Native Filter tooltip content
* @param index: tooltip index to check
* @param value: tooltip value to check
* @return {null}
* @summary helper for checking native filter tooltip content by index
************************************************************************* */
export function checkNativeFilterTooltip(index: number, value: string) {
cy.get(nativeFilters.filterConfigurationSections.infoTooltip)
.eq(index)
.trigger('mouseover');
cy.contains(`${value}`).should('be.visible');
cy.wait(100);
cy.get(nativeFilters.filterConfigurationSections.infoTooltip)
.eq(index)
.trigger('mouseout');
cy.get('body').trigger('mousemove', { clientX: 0, clientY: 0 });
cy.wait(500);
}
/** ************************************************************************
* Apply advanced time range filter on dashboard
* @param startRange: starting time range
* @param endRange: ending time range
* @return {null}
* @summary helper for applying advanced time range filter on dashboard with customize time range
************************************************************************* */
export function applyAdvancedTimeRangeFilterOnDashboard(
startRange?: string,
endRange?: string,
) {
cy.get('.control-label').contains('Range type').should('be.visible');
cy.get('.ant-popover-content .ant-select-content')
.should('be.visible')
.click();
cy.get(`[label="Advanced"]`).should('be.visible').click();
cy.get('.section-title').contains('Advanced Time Range').should('be.visible');
if (startRange) {
cy.get('.ant-popover-content')
.find('[class^=ant-input]')
.first()
.type(`${startRange}`);
}
if (endRange) {
cy.get('.ant-popover-content')
.find('[class^=ant-input]')
.last()
.type(`${endRange}`);
}
cy.get(dashboardView.timeRangeModal.applyButton).click();
cy.get(nativeFilters.applyFilter).click();
}
/** ************************************************************************
* Input default value in Native filter in filter settings
* @param defaultValue: default value for native filter
* @return {null}
* @summary helper for input default value in Native filter in filter settings
************************************************************************* */
export function inputNativeFilterDefaultValue(
defaultValue: string,
multiple = false,
) {
if (!multiple) {
cy.contains('Filter has default value').click();
cy.contains('Please choose a valid value').should('be.visible');
cy.get(nativeFilters.modal.container).within(() => {
cy.get(
nativeFilters.filterConfigurationSections.filterPlaceholder,
).contains('options');
cy.get(
nativeFilters.filterConfigurationSections.collapsedSectionContainer,
)
.eq(1)
.within(() => {
cy.get('.ant-select-input').type(`${defaultValue}{enter}`, {
force: true,
});
});
});
} else {
cy.getBySel('default-input').within(() => {
cy.get('.ant-select-input').click();
cy.get('.ant-select-item-option-content').contains(defaultValue).click();
});
}
}
/** ************************************************************************
* add filter for test column 'Country name'
* @return {null}
* @summary helper for add filter for test column 'Country name'
************************************************************************* */
export function addCountryNameFilter() {
fillNativeFilterForm(
testItems.filterType.value,
testItems.topTenChart.filterColumn,
testItems.datasetForNativeFilter,
testItems.topTenChart.filterColumn,
);
}
export function openTab(
tabComponentIndex: number,
tabIndex: number,
target = 'dashboard-component-tabs',
) {
cy.getBySel(target)
.eq(tabComponentIndex)
.find('[role="tab"]')
.eq(tabIndex)
.click();
cy.wait(500);
}
export const openTopLevelTab = (tabName: string) => {
cy.get("div#TABS-TOP div[role='tab']").contains(tabName).click();
};

View File

@@ -17,9 +17,9 @@
* under the License.
*/
import { CHART_LIST } from 'cypress/utils/urls';
import { interceptGet as interceptDashboardGet } from 'cypress/e2e/dashboard/utils';
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './visualizations/shared.helper';
import {
interceptDashboardGet,
interceptFiltering,
interceptV1ChartData,
saveChartToDashboard,

View File

@@ -17,7 +17,9 @@
* under the License.
*/
import { interceptGet as interceptDashboardGet } from '../dashboard/utils';
export function interceptDashboardGet() {
cy.intercept('GET', '**/api/v1/dashboard/*').as('get');
}
export function interceptFiltering() {
cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering');

View File

@@ -1,678 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const dataTestLocator = (value: string) => `[data-test='${value}']`;
export function dataTestChartName(chartName: string): string {
return `[data-test-chart-name='${chartName}']`;
}
export const pageHeader = {
logo: '.navbar-brand > img',
headerNavigationItem: '.ant-menu-submenu-title',
headerNavigationDropdown: "[aria-label='triangle-down']",
headerNavigationItemMenu: '.ant-menu-item-group-list',
plusIcon: ':nth-child(2) > .ant-menu-submenu-title',
plusIconMenuOptions: {
sqlQueryOption: dataTestLocator('menu-item-SQL query'),
chartOption: dataTestLocator('menu-item-Chart'),
dashboardOption: dataTestLocator('menu-item-Dashboard'),
},
plusMenu: '.ant-menu-submenu-popup',
barButtons: '[role="presentation"]',
sqlLabMenu: '[id="item_3$Menu"]',
dataMenu: '[id="item_4$Menu"]',
};
export const profile = {
activeTab: '.ant-tabs-tab-active',
inactiveTab: '.ant-tabs-tab',
emptyFavoritedPlaceholder: '.ant-empty-normal',
tableRow: '.table-row',
favoritesSpace: '#rc-tabs-0-panel-2',
};
export const securityAccess = {
rolesBubble: '.ant-badge-count',
};
export const homePage = {
homeSection: {
sectionArea: '.ant-collapse-body',
sectionElement: '.ant-card-meta-title',
},
sections: {
expandedSection: '.ant-collapse-item-active',
expandedSectionHeader: '[aria-expanded="true"]',
collapseExpandButton: '.ant-collapse-arrow',
collapsedSection: '[class="ant-collapse-item"]',
collapsedSectionHeader: '[aria-expanded="false"]',
section: '[class^="ant-collapse-item"]',
sectionsMenuContainer: "[role='navigation']",
sectionsMenuItem: "[role='menuitem']",
card: dataTestLocator('styled-card'),
},
thumbnailsSection: {
container: "[class='switch']",
toggle: "[role ='switch']",
},
};
export const databasesPage = {
addDatabaseButton: dataTestLocator('btn-create-database'),
searchInput: dataTestLocator('search-input'),
table: {
tableRow: dataTestLocator('table-row'),
tableCell: dataTestLocator('table-row-cell'),
editButton: dataTestLocator('edit-alt'),
},
modal: {
header: '#rcDialogTitle0 > h4',
step: '.helper-top',
selectDbStepTitle: '.select-db > h4',
preferredBlockBigQuery: '.preferred > :nth-child(1)',
preferredBlockPostgreSQL: '.preferred > :nth-child(2)',
preferredBlockSnowflake: '.preferred > :nth-child(3)',
preferredBlockMySQL: '.preferred > :nth-child(4)',
preferredBlockAthena: '.preferred > :nth-child(5)',
preferredBlockSheets: '.preferred > :nth-child(6)',
supportedDatabasesText: '.control-label',
orChoose: '.available-label',
dbDropdown: '.ant-select-input',
dbDropdownMenu: '.rc-virtual-list-holder-inner',
dbDropdownMenuItem: '[class="ant-select-item-option-content"]',
infoAlert: '.ant-alert',
serviceAccountInput: '[name="credentials_info"]',
connectionStep: {
modal: '.ant-modal-container',
modalBody: '.ant-modal-body',
stepTitle: '.css-7x6kk > h4',
helperBottom: '.helper-bottom',
postgresDatabase: '[name="database"]',
dbInput: '[name="database_name"]',
alertMessage: '.ant-alert-message',
errorField: '[role="alert"]',
uploadJson: '[title="Upload JSON file"]',
chooseFile: '[class="ant-btn input-upload-btn"]',
additionalParameters: '[name="query_input"]',
sqlAlchemyUriInput: dataTestLocator('sqlalchemy-uri-input'),
advancedTab: '#rc-tabs-0-tab-2',
activeTab: '.ant-tabs-tab-active',
securitySubMenu:
':nth-child(3) > .ant-collapse-header > .anticon > svg > path',
aceTextInput: '.ace_text-input',
aceContent: '.ace_content',
connectButton: '.css-16i3wh7',
hostInput: '[name="host"]',
portInput: '[name="port"]',
usernameInput: '[name="username"]',
passwordInput: '[name="password"]',
switch: '.ant-switch',
},
},
};
export const sqlLabView = {
sqlEditorLeftBar: {
sqlEditorLeftBar: '[data-test="sql-editor-left-bar"]',
databaseSchemaTableSection:
'[data-test="sql-editor-left-bar"] > :nth-child(1)',
tableSchemaSection:
'[data-test="sql-editor-left-bar"] > :nth-child(1) > :nth-child(3) > :nth-child(1)',
tableSchemaInputEmpty: '[aria-label="Select table or type table name"]',
},
databaseInput: '[data-test=DatabaseSelector] > :nth-child(1)',
emptyMenuOptionsPlaceholder: '[class="ant-empty-img-simple"]',
removeTabButton: '.ant-tabs-tab-remove > .anticon > svg',
tabsNavList: "[class='ant-tabs-nav-list']",
tab: "[class='ant-tabs-tab-btn']",
addTabButton: dataTestLocator('add-tab-icon'),
tooltip: '.ant-tooltip-content',
tabName: '.css-1suejie',
schemaInput: '[data-test=DatabaseSelector] > :nth-child(2)',
loadingIndicator: '.Select__loading-indicator',
menuItem: '[class="ant-select-item-option-content"]',
examplesMenuItem: '[title="examples"]',
tableInput: ':nth-child(4) > .select > :nth-child(1)',
sqlEditor: '#brace-editor textarea',
saveAsButton: '.SaveQuery > .ant-btn',
saveAsModal: {
footer: '.ant-modal-footer',
queryNameInput: 'input[class^="ant-input"]',
},
sqlToolbar: {
toolbar: '#js-sql-toolbar',
runButton: '.css-d3dxop',
},
rowsLimit: {
dropdown: '.ant-dropdown-menu',
limitButton: '.ant-dropdown-menu-item',
limitButtonText: '.css-151uxnz',
limitTextWithValue: '[class="ant-dropdown-trigger"]',
},
renderedTableHeader: '.ReactVirtualized__Table__headerRow',
renderedTableRow: '.ReactVirtualized__Table__row',
errorBody: '.error-body',
alertMessage: '.ant-alert-message',
historyTable: {
header: '[role=columnheader]',
table: '.QueryTable',
row: dataTestLocator('table-row'),
failureMarkIcon: '[aria-label=close]',
successMarkIcon: '[aria-label=check]',
},
};
export const queryHistory = {
tableHeader: dataTestLocator('sort-header'),
row: dataTestLocator('table-row'),
cell: dataTestLocator('table-row-cell'),
action: '[aria-label=full]',
};
export const savedQuery = {
tableHeader: dataTestLocator('sort-header'),
row: dataTestLocator('table-row'),
cell: dataTestLocator('table-row-cell'),
trash: dataTestLocator('delete-action'),
deleteQueryIcon: '[data-test="delete-action"]',
deleteModal: {
deleteInput: dataTestLocator('delete-modal-input'),
deleteButton: dataTestLocator('modal-confirm-button'),
},
};
export const annotationLayersView = {
emptyDescription: {
description: '.ant-empty-description',
addAnnotationLayerButton: '.ant-empty-footer > .ant-btn',
},
modal: {
content: {
content: '.ant-modal-body',
title: '.ant-modal-body > :nth-child(2) > input',
description: "[name='descr']",
},
footer: {
footer: '.ant-modal-footer',
addButton: dataTestLocator('modal-confirm-button'),
cancelButton: dataTestLocator('modal-cancel-button'),
},
},
};
export const datasetsList = {
newDatasetButton: '.css-yff34v',
searchInput: dataTestLocator('filters-search'),
newDatasetModal: {
inputField: '[class="section"]',
addButton: dataTestLocator('modal-confirm-button'),
body: '.ant-modal-body',
},
table: {
tableRow: {
row: dataTestLocator('table-row'),
rowText: dataTestLocator('cell-text'),
rowCell: dataTestLocator('table-row-cell'),
},
tableRowHeader: {
header: dataTestLocator('sort-header'),
unsorted: '[aria-label="sort"]',
sortAsc: '[aria-label="sort-asc"]',
sortDesc: '[aria-label="sort-desc"]',
},
rowActionItems: dataTestLocator('table-row-cell'),
actionButton: '.action-button',
editButton: '[aria-label="edit-alt"]',
},
editDatasetModal: {
modal: dataTestLocator('edit-dataset-tabs'),
sourceNavigationTab: '#table-tabs-tab-0',
metricsNavigationTab: dataTestLocator('collection-tab-Metrics'),
columnsNavigationTab: dataTestLocator('collection-tab-Columns'),
calculatedColumnsNavigationTab: dataTestLocator(
'collection-tab-Calculated columns',
),
settingsNavigationTab: '#table-tabs-tab-4',
saveButton: dataTestLocator('datasource-modal-save'),
sourceContent: '#table-tabs-panel-0',
metricsContent: '#table-tabs-panel-1',
columnsContent: '#table-tabs-panel-2',
calculatedColumnsContent: '#table-tabs-panel-3',
settingsContent: '#table-tabs-panel-4',
aceTextInput: '.ace_text-input',
sourceSQLInput: '.ace_content',
sourceVirtualSQLRadio: ':nth-child(2) > .ant-radio > .ant-radio-inner',
sourcePadlock: '[aria-label=lock]',
legacy: {
panel: '.panel-body',
sqlInput: '#sql',
save: '.well > .btn-primary',
description: '#description',
},
},
deleteDatasetModal: {
modal: '.ant-modal-container',
deleteInput: dataTestLocator('delete-modal-input'),
deleteButton: dataTestLocator('modal-confirm-button'),
text: '.css-kxmt87',
},
};
export const chartListView = {
filtersBar: {
searchField: dataTestLocator('filters-search'),
},
top: {
bulkSelect: dataTestLocator('bulk-select'),
},
header: {
cardView: '[aria-label="card-view"]',
listView: '[aria-label="list-view"]',
sort: '.ant-select-input[aria-label="Sort"]',
sortRecentlyModifiedMenuOption: '[label="Recently modified"]',
sortAlphabeticalMenuOption: '[label="Alphabetical"]',
sortDropdown: '.Select__menu',
},
card: {
card: dataTestLocator('styled-card'),
cardCover: '[class="ant-card-cover"]',
cardImage: '[class="gradient-container"]',
starIcon: dataTestLocator('fave-unfave-icon'),
},
deleteModal: {
deleteInput: dataTestLocator('delete-modal-input'),
deleteButton: dataTestLocator('modal-confirm-button'),
},
table: {
bulkSelect: {
checkboxOff: 'input[type="checkbox"]:checked',
checkboxOn: 'input[type="checkbox"]:not(:checked)',
action: dataTestLocator('bulk-select-action'),
},
tableList: dataTestLocator('listview-table'),
table: '[role="rowgroup"]',
tableRowHeader: {
header: dataTestLocator('sort-header'),
unsorted: '[aria-label="sort"]',
sortAsc: '[aria-label="sort-asc"]',
sortDesc: '[aria-label="sort-desc"]',
},
tableRow: {
row: dataTestLocator('table-row'),
rowText: dataTestLocator('cell-text'),
rowCell: dataTestLocator('table-row-cell'),
},
},
addChartButton: '.css-yff34v',
};
export const nativeFilters = {
modal: {
container: '.ant-modal',
footer: '.ant-modal-footer',
saveButton: dataTestLocator('native-filter-modal-save-button'),
cancelButton: dataTestLocator('native-filter-modal-cancel-button'),
confirmCancelButton: dataTestLocator(
'native-filter-modal-confirm-cancel-button',
),
alertXUnsavedFilters: '.ant-alert-message',
tabsList: {
filterItemsContainer: dataTestLocator('filter-title-container'),
tabsContainer: '[class="ant-tabs-nav-list"]',
tab: '.ant-tabs-tab',
removeTab: '[aria-label="delete"]',
},
addFilter: dataTestLocator('add-filter-button'),
defaultValueCheck: '.ant-checkbox-checked',
addNewFilterButton: dataTestLocator('add-new-filter-button'),
addNewDividerButton: dataTestLocator('add-new-divider-button'),
},
filtersPanel: {
filterName: dataTestLocator('filters-config-modal__name-input'),
datasetName: dataTestLocator('filters-config-modal__datasource-input'),
filterInfoInput: '.ant-select-input',
inputDropdown: '.ant-select-item-option-content',
columnEmptyInput: '.ant-select-placeholder',
filterTypeInput: dataTestLocator('filters-config-modal__filter-type'),
fieldInput: dataTestLocator('field-input'),
filterTypeItem: '.ant-select-content-has-value, .ant-select-selection-item',
filterGear: dataTestLocator('filterbar-orientation-icon'),
},
filterFromDashboardView: {
filterValueInput: '.ant-select-input',
expand: dataTestLocator('filter-bar__expand-button'),
collapse: dataTestLocator('filter-bar__collapse-button'),
filterName: dataTestLocator('filter-control-name'),
filterContent: '.ant-select-content-has-value, .ant-select-selection-item',
createFilterButton: dataTestLocator('filter-bar__create-filter'),
timeRangeFilterContent: dataTestLocator('time-range-trigger'),
},
createFilterButton: dataTestLocator('filter-bar__create-filter'),
removeFilter: '[aria-label="remove"]',
silentLoading: '.loading inline-centered css-101mkpk',
filterConfigurationSections: {
sectionHeader: '.ant-collapse-header',
displayedSection: 'div[style="height: 100%; overflow-y: auto;"]',
collapseExpandButton: '.ant-collapse-arrow',
checkedCheckbox: '.ant-checkbox-wrapper-checked',
infoTooltip: '[aria-label="Show info tooltip"]',
parentFilterInput: dataTestLocator('parent-filter-input'),
filterPlaceholder: '.ant-select-placeholder',
collapsedSectionContainer: '[class="ant-collapse-body"]',
},
filtersList: {
list: '.ant-tabs-nav-list',
listItemNotActive: '[class="ant-tabs-tab ant-tabs-tab-with-remove"]',
listItemActive:
'[class="ant-tabs-tab ant-tabs-tab-with-remove ant-tabs-tab-active"]',
removeIcon: '[aria-label="delete"]',
},
filterItem: dataTestLocator('form-item-value'),
filterItemDropdown: '.ant-select-input',
applyFilter: dataTestLocator('filter-bar__apply-button'),
defaultInput: dataTestLocator('default-input'),
filterIcon: dataTestLocator('filter-icon'),
slider: {
slider: '[class="ant-slider"]',
startHandle: '[class="ant-slider-handle ant-slider-handle-1"]',
endHandle: '[class="ant-slider-handle ant-slider-handle-2"]',
sliderText: '[class="ant-slider-mark-text ant-slider-mark-text-active"]',
},
};
export const dashboardListView = {
dashboardListView: dataTestLocator('dashboard-list-view'),
editItemAction: dataTestLocator('edit-alt'),
filtersBar: {
searchField: dataTestLocator('filters-search'),
},
top: {
bulkSelect: dataTestLocator('bulk-select'),
},
card: {
card: dataTestLocator('styled-card'),
cardCover: '[class="ant-card-cover"]',
cardImage: '[class="gradient-container"]',
selectedStarIcon: "[aria-label='star']",
unselectedStarIcon: "[aria-label='star']",
starIcon: dataTestLocator('fave-unfave-icon'),
},
deleteModal: {
deleteInput: dataTestLocator('delete-modal-input'),
deleteButton: dataTestLocator('modal-confirm-button'),
},
table: {
starIcon: dataTestLocator('fave-unfave-icon'),
selectedStarIcon: "[aria-label='star']",
unselectedStarIcon: "[aria-label='star']",
bulkSelect: {
checkboxOff: '[aria-label="checkbox-off"]',
checkboxOn: '[aria-label="checkbox-on"]',
action: dataTestLocator('bulk-select-action'),
},
tableRow: {
sortHeader: dataTestLocator('sort-header'),
row: dataTestLocator('table-row'),
rowText: dataTestLocator('cell-text'),
rowCell: dataTestLocator('table-row-cell'),
},
tableRowList: '[role="rowgroup"]',
tableList: dataTestLocator('listview-table'),
trashIcon: dataTestLocator('dashboard-list-trash-icon'),
},
actions: {
importButton: dataTestLocator('import-button'),
newDashboardButton: '.css-yff34v',
},
importModal: {
selectFileButton: '.ant-upload > .ant-btn > span',
importButton: dataTestLocator('modal-confirm-button'),
},
header: {
cardView: '[aria-label="appstore"]',
listView: '[aria-label="unordered-list"]',
sort: dataTestLocator('sort-header'),
sortDropdown: '.Select__menu',
statusFilterInput: `${dataTestLocator(
'filters-select',
)}[aria-label='Status']`,
ownerFilterInput: `${dataTestLocator(
'filters-select',
)}[aria-label='Owner']`,
createdByFilterInput: `${dataTestLocator(
'filters-select',
)}[aria-label='Created by']`,
FavoriteFilterInput: `${dataTestLocator(
'filters-select',
)}[aria-label='Favorite']`,
searchFilterInput: `${dataTestLocator(
'filters-search',
)}[name='dashboard_title']`,
draftStatusFilterOption: "[title='Draft']",
},
};
export const exploreView = {
openDatasourceMenu: dataTestLocator('open-datasource-tab'),
sectionsHeader: '.ant-collapse-header',
datasourceMenuThreeDots: dataTestLocator('datasource-menu-trigger'),
threeDotsMenuDropdown: {
editDataset: dataTestLocator('edit-dataset'),
},
chartContainer: dataTestLocator('chart-container'),
chartHeading: {
queryMenu: '#query',
viewQueryButton: dataTestLocator('view-query-menu-item'),
shortLinkButton: dataTestLocator('short-link-button'),
embedCodeButton: dataTestLocator('embed-code-button'),
},
chartAreaItem: '.nv-legend-text',
viewQueryModal: {
container: '.ant-modal-container',
closeButton: 'button.ant-modal-close',
},
embedCodeModal: {
container: dataTestLocator('embed-code-popover'),
textfield: dataTestLocator('embed-code-textarea'),
},
saveModal: {
modal: '.ant-modal-container',
chartNameInput: dataTestLocator('new-chart-name'),
dashboardNameInput: '.ant-select-input',
addToDashboardInput: dataTestLocator(
'save-chart-modal-select-dashboard-form',
),
saveButton: dataTestLocator('btn-modal-save'),
saveAsCircle: dataTestLocator('saveas-radio'),
overwriteCircle: dataTestLocator('save-overwrite-radio'),
saveAndGoToDashboard: '#btn_modal_save_goto_dash',
},
controlPanel: {
panel: dataTestLocator('control-tabs'),
categoryArea: '.ant-collapse-body',
dragField: dataTestLocator('datasource'),
metricsField: dataTestLocator('metrics'),
optionField: dataTestLocator('option-label'),
fieldInput: '.Select__control input',
removeFieldValue: dataTestLocator('remove-control-button'),
addFieldValue: '[aria-label="plus"]',
vizType: dataTestLocator('visualization-type'),
runButton: dataTestLocator('run-query-button'),
saveQuery: dataTestLocator('query-save-button'),
fieldValue: dataTestLocator('control-label'),
addMetricModal: {
container: dataTestLocator('metrics-edit-popover'),
metricNameEdit: {
editButton: dataTestLocator('AdhocMetricEditTitle#trigger'),
input: dataTestLocator('AdhocMetricEditTitle#input'),
},
simpleTab: {
tab: '#adhoc-metric-edit-tabs-tab-SIMPLE',
columnInput: '[name="select-column"]',
aggregateInput: '[name="select-aggregate"]',
},
customSQLTab: {
tab: '#adhoc-metric-edit-tabs-tab-SQL',
inputContainerField: '.ace_content',
input: '.ace_text-input',
},
saveButton: dataTestLocator('AdhocMetricEdit#save'),
},
querySection: {
groupByField: dataTestLocator('groupby'),
filtersField: dataTestLocator('adhoc_filters'),
filterValue: dataTestLocator('adhoc-filter-simple-value'),
filterModal: {
customSQL: {
tab: '#adhoc-filter-edit-tabs-tab-SQL',
inputArea: '.ace_content',
input: '.ace_text-input',
},
simple: {
tab: '#adhoc-filter-edit-tabs-tab-SIMPLE',
},
container: dataTestLocator('filter-edit-popover'),
saveButton: dataTestLocator('adhoc-filter-edit-popover-save-button'),
},
},
annotationLayerSection: {
addAnnotationLayer: dataTestLocator('annotation_layers'),
addAnnotationLayerModal: {
container: dataTestLocator('popover-content'),
name: dataTestLocator('annotation-layer-name-header'),
formula: dataTestLocator('annotation-layer-value-header'),
input: dataTestLocator(''),
},
},
timeSection: {
timeRangeFilter: dataTestLocator('time-range-trigger'),
timeRangeFilterModal: {
container: '.ant-popover-content',
footer: '.footer',
cancelButton: dataTestLocator('cancel-button'),
configureLastTimeRange: {
container: '.ant-radio-group',
checkedItem: '.ant-radio-checked + span',
},
configureCustomTimeRange: {
container: dataTestLocator('custom-frame'),
timeInput: '.ant-input-number-input-wrap > input',
},
configureNoFilterTimeRange: {
container: dataTestLocator('no-filter'),
},
},
},
advancedAnalyticsSection: {
timeShift: {
container: dataTestLocator('time_compare'),
input: 'input[type=text]',
selectedValues: '.Select__multi-value__label',
},
},
},
editDatasetModal: {
container: '.ant-modal-container',
datasetTabsContainer: dataTestLocator('edit-dataset-tabs'),
saveButton: dataTestLocator('datasource-modal-save'),
metricsTab: {
addItem: dataTestLocator('crud-add-table-item'),
rowsContainer: '.ant-table-body',
},
confirmModal: {
okButton: '.ant-modal-confirm-btns .ant-btn-primary',
},
},
visualizationTypeModal: {
vizTypeButton: dataTestLocator('viztype-selector-container'),
},
};
export const createChartView = {
chooseDatasetInput: '.ant-select-input',
chooseDatasetOption: '.ant-select-item-option-content',
chooseDatasetList: '.rc-virtual-list-holder-inner',
tableVizType: "[alt='Table']",
};
export const editDashboardView = {
dashboardName: dataTestLocator('dashboard-title-input'),
dashboardEditingContainer: dataTestLocator('dashboard-content'),
editComponent: dataTestLocator('new-component'),
discardChanges: dataTestLocator('discard-changes-button'),
chartBox: dataTestLocator('chart-grid-component'),
tabsList: {
tabsContainer: '[class="ant-tabs-nav-list"]',
tab: '.ant-tabs-tab',
},
};
export const dashboardView = {
dashboardContainer: dataTestLocator('grid-container'),
dashboardAlert: {
modal: dataTestLocator('toast-container'),
closeButton: dataTestLocator('close-button'),
},
saveModal: {
modal: '.ant-modal-container',
dashboardNameInput: '.ant-input',
saveButton: dataTestLocator('modal-save-dashboard-button'),
},
dashboardProperties: {
modal: '.ant-modal-container',
dashboardTitleInput: dataTestLocator('dashboard-title-input'),
modalButton: '[type="button"]',
},
chart: {
trashIcon: dataTestLocator('dashboard-delete-component-button'),
refreshChart: dataTestLocator('refresh-chart-menu-item'),
},
threeDotsMenuIcon:
'.header-with-actions .right-button-panel .ant-dropdown-trigger',
threeDotsMenuDropdown: dataTestLocator('header-actions-menu'),
refreshDashboard: dataTestLocator('refresh-dashboard-menu-item'),
saveAsMenuOption: dataTestLocator('save-as-menu-item'),
dashboardTab: dataTestLocator('dragdroppable-object'),
salesDashboardSpecific: {
vehicleSalesFilterTimeRange: dataTestLocator('time-range-trigger'),
secondTabSalesDashboard: dataTestLocator('dragdroppable-object'),
},
timeRangeModal: {
rangeTypeField: '.ant-select-content-has-value, .ant-select-selection-item',
startTimeInputNumber: '.ant-input-number-input',
datePicker: '.ant-picker-input',
applyButton: dataTestLocator('date-filter-control__apply-button'),
radioGroup: '.ant-radio-group',
radioButton: '.ant-radio',
finalFilterRangePreviewCustom:
'.css-c2bup7 > :nth-child(6) > :nth-child(2)',
finalFilterRangePreviewLast: '.css-c2bup7 > :nth-child(7) > :nth-child(2)',
finalFilterRangePreviewPrevious:
'.css-c2bup7 > :nth-child(7) > :nth-child(2)',
},
treeMapChartModal: {
selectItem: '.Select_control',
selectItemInput: '.Select__control input[type=text]',
applyButton: '.filter button:not(:disabled)',
clearItemIcon: '.Select__clear-indicator',
},
sliceThreeDots: '[aria-label="More Options"]',
sliceThreeDotsDropdown: '[role="menu"]',
editDashboardButton: '[aria-label="Edit dashboard"]',
starIcon: dataTestLocator('fave-unfave-icon'),
dashboardHeader: dataTestLocator('dashboard-header'),
dashboardSectionContainer: dataTestLocator(
'grid-row-background--transparent',
),
markdownEditor: dataTestLocator('dashboard-markdown-editor'),
dashboardComponent: dataTestLocator('dashboard-component-chart-holder'),
tabsList: {
tabsContainer: dataTestLocator('dashboard-component-tabs'),
tabsNavList: dataTestLocator('nav-list'),
tabs: '.ant-tabs-nav-list',
tab: '.ant-tabs-tab',
},
};

View File

@@ -20,7 +20,5 @@
export const DASHBOARD_LIST = '/dashboard/list/';
export const CHART_LIST = '/chart/list/';
export const WORLD_HEALTH_DASHBOARD = '/dashboard/world_health/';
export const SAMPLE_DASHBOARD_1 = '/dashboard/1-sample-dashboard/';
export const SUPPORTED_CHARTS_DASHBOARD = '/dashboard/supported_charts_dash/';
export const TABBED_DASHBOARD = '/dashboard/tabbed_dash/';
export const DATABASE_LIST = '/databaseview/list';

View File

@@ -45,9 +45,9 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^6.10.0",
"@rjsf/core": "^6.10.1",
"@rjsf/utils": "^6.6.2",
"@rjsf/validator-ajv8": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.1",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -78,8 +78,8 @@
"@visx/scale": "^4.0.0",
"@visx/tooltip": "^4.0.0",
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"ag-grid-community": "36.2.0",
"ag-grid-react": "36.2.0",
"antd": "^6.6.4",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
@@ -89,8 +89,6 @@
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.4.0",
"fuse.js": "^7.5.0",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
@@ -98,7 +96,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.2",
"google-auth-library": "^11.1.0",
"immer": "^11.1.18",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -107,8 +105,8 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.30.0",
"markdown-to-jsx": "^9.10.2",
"mapbox-gl": "^3.31.0",
"markdown-to-jsx": "^9.10.3",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
@@ -203,7 +201,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.5.1",
"@types/node": "^26.6.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -221,7 +219,7 @@
"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.23",
"baseline-browser-mapping": "^2.11.25",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -248,14 +246,14 @@
"jest-html-reporter": "^4.4.2",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^30.0.1",
"jsdom": "^30.1.0",
"lerna": "^10.0.1",
"lightningcss": "^1.33.0",
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.10.1",
"open-cli": "^9.0.0",
"oxfmt": "^0.68.0",
"oxlint": "^1.82.0",
"oxlint": "^1.83.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"process": "^0.11.10",
@@ -276,7 +274,7 @@
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.111.0",
"webpack-bundle-analyzer": "^5.3.3",
"webpack-bundle-analyzer": "^5.4.0",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0",
"webpack-manifest-plugin": "^6.0.1",
@@ -445,20 +443,20 @@
"link": true
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.2.1",
"@csstools/css-color-parser": "^4.1.9",
"@csstools/css-calc": "^3.3.0",
"@csstools/css-color-parser": "^4.2.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
@@ -472,19 +470,19 @@
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
"version": "9.1.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
"integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.3",
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": {
@@ -2794,9 +2792,9 @@
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
@@ -2814,9 +2812,9 @@
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
"dev": true,
"funding": [
{
@@ -2838,9 +2836,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
"dev": true,
"funding": [
{
@@ -2854,8 +2852,8 @@
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.3.0"
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.4.0"
},
"engines": {
"node": ">=20.19.0"
@@ -8284,6 +8282,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -8297,6 +8296,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -8306,6 +8306,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -10275,9 +10276,9 @@
}
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz",
"integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz",
"integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==",
"cpu": [
"arm"
],
@@ -10292,9 +10293,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz",
"integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz",
"integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==",
"cpu": [
"arm64"
],
@@ -10309,9 +10310,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz",
"integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz",
"integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==",
"cpu": [
"arm64"
],
@@ -10326,9 +10327,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz",
"integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz",
"integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==",
"cpu": [
"x64"
],
@@ -10343,9 +10344,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz",
"integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz",
"integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==",
"cpu": [
"x64"
],
@@ -10360,9 +10361,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz",
"integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz",
"integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==",
"cpu": [
"arm"
],
@@ -10377,9 +10378,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz",
"integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz",
"integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==",
"cpu": [
"arm"
],
@@ -10394,9 +10395,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz",
"integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz",
"integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==",
"cpu": [
"arm64"
],
@@ -10414,9 +10415,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz",
"integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz",
"integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==",
"cpu": [
"arm64"
],
@@ -10434,9 +10435,9 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz",
"integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz",
"integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==",
"cpu": [
"ppc64"
],
@@ -10454,9 +10455,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz",
"integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz",
"integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==",
"cpu": [
"riscv64"
],
@@ -10474,9 +10475,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz",
"integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz",
"integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==",
"cpu": [
"riscv64"
],
@@ -10494,9 +10495,9 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz",
"integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz",
"integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==",
"cpu": [
"s390x"
],
@@ -10514,9 +10515,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz",
"integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz",
"integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==",
"cpu": [
"x64"
],
@@ -10534,9 +10535,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz",
"integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz",
"integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==",
"cpu": [
"x64"
],
@@ -10554,9 +10555,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz",
"integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz",
"integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==",
"cpu": [
"arm64"
],
@@ -10571,9 +10572,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz",
"integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz",
"integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==",
"cpu": [
"arm64"
],
@@ -10588,9 +10589,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz",
"integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz",
"integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==",
"cpu": [
"ia32"
],
@@ -10605,9 +10606,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz",
"integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz",
"integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==",
"cpu": [
"x64"
],
@@ -12151,9 +12152,9 @@
"license": "MIT"
},
"node_modules/@rjsf/core": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz",
"integrity": "sha512-fdyaPnhIe+NzTFZcYcYcp/QqVcAJhhloIsU6vN+hp/IhiGJrzgB9REMVVUlKYNzP37xZ/BsEq7u7RC24KKdLAQ==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.1.tgz",
"integrity": "sha512-LHLbXYEq1+c2CpynBlf5JBlUhPNxOkVV+txf1BvamgJSt4QOisXFw5Uv+mBdLRi4BI+FfT0ofMVOgGWnuwG7gw==",
"license": "Apache-2.0",
"dependencies": {
"markdown-to-jsx": "^9.8.2"
@@ -12162,17 +12163,17 @@
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.10.0",
"@rjsf/utils": "^6.10.1",
"react": ">=18"
}
},
"node_modules/@rjsf/utils": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz",
"integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.1.tgz",
"integrity": "sha512-dlbQUCoTl/+/TenyYs1zAKXf9sLTebtY+6pB+bUFsaN4xkdAtR8YBLwmpa1N5yNiRzf3fbxveISNuZmDrZE/yQ==",
"license": "Apache-2.0",
"dependencies": {
"@x0k/json-schema-merge": "^1.0.3",
"@x0k/json-schema-merge": "^1.0.6",
"fast-equals": "^6.0.0",
"fast-uri": "^4.1.4",
"jsonpointer": "^5.0.1",
@@ -12186,9 +12187,9 @@
}
},
"node_modules/@rjsf/validator-ajv8": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.0.tgz",
"integrity": "sha512-B/cNuPIQNBiJo4ByoJUXwGylu51JA5Jhkj4gsZ794dl85xxfcQ43HKfR1Lg55SoCUOzHc0Sf7zI/0q3R3sKEEQ==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.1.tgz",
"integrity": "sha512-Jyim45UKd39SgWn4ryaydTigZ0dddNEeqZUfTlLgsy2v23uMDLyeKjNCItC09dy00dtKzwM3Q4WA0FtQ5IjOgA==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.20.0",
@@ -12198,7 +12199,7 @@
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.10.0"
"@rjsf/utils": "^6.10.1"
}
},
"node_modules/@rtsao/scc": {
@@ -14498,9 +14499,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.5.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
"version": "26.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
"integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
@@ -16325,9 +16326,9 @@
"license": "MIT"
},
"node_modules/@x0k/json-schema-merge": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.4.tgz",
"integrity": "sha512-KvmMgAftbVzATq4IRnkno/SKSu+gjaR2ZUPJG5JUlY4W3twRJo03sk2914u8scmosibBZ0m7s6euZlJuqpv8Ww==",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.6.tgz",
"integrity": "sha512-SSdWAhirRj9UGTSjmgMnchC3Nytpbde46qPEXVQLm/S3opteW6E/VX4UzLlfmA9L2zdkmG3c69sfLfHbT0jl0w==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15"
@@ -16529,28 +16530,28 @@
}
},
"node_modules/ag-charts-types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.1.0.tgz",
"integrity": "sha512-mmzkng88c0l+Z9PvCMowMilhVeNgDU/iMuMemcOQD4BG/qO4vbkxWvyQO0iqju8Dx7YOY81PNzC/RmElQNiVkA==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.2.0.tgz",
"integrity": "sha512-4pDV6/4zbzPN7t607Gr1o6DO9RhmPafQGr+hIpTJBaerbaMoC+WOO3v/wkxFbvCNrHKOexEKSfNTH7PaAR4UzA==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "36.1.0",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.1.0.tgz",
"integrity": "sha512-WnvSQ4csRs8gv/b1B0lPHykQXvUJVgi2u5mpY0Aa6dv3pvhDVVqCT8dwUyOeCog4JNPptaXGrOrZ8qP2XoJzVA==",
"version": "36.2.0",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.2.0.tgz",
"integrity": "sha512-tK0Toj7t0fV/OMLPnDc0xlYKAqDbnCT3iJodYSn2TJ541caU8jPdYwWhMdW+AP2+pUNwFiJVgc04eJNfvBPsWw==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "14.1.0",
"ag-stack": "36.1.0"
"ag-charts-types": "14.2.0",
"ag-stack": "36.2.0"
}
},
"node_modules/ag-grid-react": {
"version": "36.1.0",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.1.0.tgz",
"integrity": "sha512-FNsmrOmr+taZY11sCA+6Ow0mKs2JqnmrjBrMjkKC2rVTXPARtks+tv+Ne4ZkLGGtvIMDlBFCDbSzFXVP0CdwXQ==",
"version": "36.2.0",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.2.0.tgz",
"integrity": "sha512-7dsaR4Ga8/bJAOov7XJqxsDc1JAjUU5Bmh9+s4U9+/TKIdSsaVo53SuZb2yEFSB1elv3eh6aZP8a1fqGZehXwA==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "36.1.0",
"ag-grid-community": "36.2.0",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@@ -16559,9 +16560,9 @@
}
},
"node_modules/ag-stack": {
"version": "36.1.0",
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.1.0.tgz",
"integrity": "sha512-Kmkf5iRZmyduNx2KtW450j6GbCdJ4ejacSpb1AWl0yrdquRmScLRc1SlLolx6lyCbTBSJGo4tRraKbBzre+GlQ==",
"version": "36.2.0",
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.2.0.tgz",
"integrity": "sha512-f3XT2htFs9dUPcHni2z9ak69QNta5ODEpooHuuNvbjN98JkokpEqRQ9YLQHQJFdCcgi9Ybs/j3zkkvmNRamIPg==",
"license": "MIT"
},
"node_modules/agent-base": {
@@ -17720,9 +17721,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.23",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
"integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==",
"version": "2.11.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
"integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -17740,9 +17741,9 @@
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17991,6 +17992,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -22648,6 +22650,7 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -22771,6 +22774,7 @@
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
"integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -22937,6 +22941,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -23496,6 +23501,7 @@
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
@@ -24459,6 +24465,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -24630,9 +24637,9 @@
"license": "MIT"
},
"node_modules/google-auth-library": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz",
"integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.1.0.tgz",
"integrity": "sha512-YIBq5KNoHQrZ9PM4PeodDBqqBGlSTIxh1zibN8u3zNIO9UJg8gvKuCpdKV7fWXhsZ2zRkU6FhGrgKNgLJ2IRgw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
@@ -24702,6 +24709,7 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/h3-js": {
@@ -26107,6 +26115,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -26169,6 +26178,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -26276,6 +26286,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -30774,16 +30785,16 @@
}
},
"node_modules/jsdom": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.1.2",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
@@ -30793,13 +30804,12 @@
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"undici": "^8.10.2",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
@@ -30815,9 +30825,9 @@
}
},
"node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
@@ -30915,9 +30925,9 @@
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -30945,9 +30955,9 @@
}
},
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"version": "17.1.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -31059,6 +31069,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
@@ -32455,9 +32466,9 @@
}
},
"node_modules/mapbox-gl": {
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.30.0.tgz",
"integrity": "sha512-26m0CSBszLkvbdVWf2oIZ3SjqCuResxJKEbupwwSyNwbwJAcgnMhDy2Yl5cSgtUxeamUJ1UeXRqpBmGEUVaAeQ==",
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.31.0.tgz",
"integrity": "sha512-7i25NyCPW5jnqsqJq3irgBBEoVTJgfZqTJJS446Q3ZuxyAb5Om75UIcxYIo38oz9eDswnBXr0riz+M/C8V7g3Q==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
@@ -32550,9 +32561,9 @@
}
},
"node_modules/markdown-to-jsx": {
"version": "9.10.2",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
"integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
"version": "9.10.3",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.3.tgz",
"integrity": "sha512-J5/NCmNYQ0ExsnThzvF3F1fSzNYLgROcW2diUqRqoIoiyhoy8DSI06jLuMYk0c86SQd/FiVYJrV6tIgci9S2tQ==",
"license": "MIT",
"engines": {
"node": ">= 18"
@@ -33089,6 +33100,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -33667,6 +33679,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -35889,9 +35902,9 @@
}
},
"node_modules/oxlint": {
"version": "1.82.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz",
"integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==",
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz",
"integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -35904,25 +35917,25 @@
"url": "https://github.com/sponsors/oxc-project"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.82.0",
"@oxlint/binding-android-arm64": "1.82.0",
"@oxlint/binding-darwin-arm64": "1.82.0",
"@oxlint/binding-darwin-x64": "1.82.0",
"@oxlint/binding-freebsd-x64": "1.82.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.82.0",
"@oxlint/binding-linux-arm-musleabihf": "1.82.0",
"@oxlint/binding-linux-arm64-gnu": "1.82.0",
"@oxlint/binding-linux-arm64-musl": "1.82.0",
"@oxlint/binding-linux-ppc64-gnu": "1.82.0",
"@oxlint/binding-linux-riscv64-gnu": "1.82.0",
"@oxlint/binding-linux-riscv64-musl": "1.82.0",
"@oxlint/binding-linux-s390x-gnu": "1.82.0",
"@oxlint/binding-linux-x64-gnu": "1.82.0",
"@oxlint/binding-linux-x64-musl": "1.82.0",
"@oxlint/binding-openharmony-arm64": "1.82.0",
"@oxlint/binding-win32-arm64-msvc": "1.82.0",
"@oxlint/binding-win32-ia32-msvc": "1.82.0",
"@oxlint/binding-win32-x64-msvc": "1.82.0"
"@oxlint/binding-android-arm-eabi": "1.83.0",
"@oxlint/binding-android-arm64": "1.83.0",
"@oxlint/binding-darwin-arm64": "1.83.0",
"@oxlint/binding-darwin-x64": "1.83.0",
"@oxlint/binding-freebsd-x64": "1.83.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.83.0",
"@oxlint/binding-linux-arm-musleabihf": "1.83.0",
"@oxlint/binding-linux-arm64-gnu": "1.83.0",
"@oxlint/binding-linux-arm64-musl": "1.83.0",
"@oxlint/binding-linux-ppc64-gnu": "1.83.0",
"@oxlint/binding-linux-riscv64-gnu": "1.83.0",
"@oxlint/binding-linux-riscv64-musl": "1.83.0",
"@oxlint/binding-linux-s390x-gnu": "1.83.0",
"@oxlint/binding-linux-x64-gnu": "1.83.0",
"@oxlint/binding-linux-x64-musl": "1.83.0",
"@oxlint/binding-openharmony-arm64": "1.83.0",
"@oxlint/binding-win32-arm64-msvc": "1.83.0",
"@oxlint/binding-win32-ia32-msvc": "1.83.0",
"@oxlint/binding-win32-x64-msvc": "1.83.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=7.0.2001",
@@ -37430,6 +37443,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -38853,10 +38867,9 @@
}
},
"node_modules/react-draggable": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
"dev": true,
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.2.tgz",
"integrity": "sha512-pmqDx72EqhMGoJe/x42ZO2wJ9TIz7qJ5OPnFu6lVWWznEuJumlSjR+J0V4tNyDFhUo6dVvv56FeiTqN+arTgnw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
@@ -40160,6 +40173,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -40246,6 +40260,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -43256,6 +43271,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -44265,6 +44281,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
@@ -44973,9 +44990,9 @@
}
},
"node_modules/webpack-bundle-analyzer": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.3.tgz",
"integrity": "sha512-kr/bzg3sORg1xAnxpg1uaIPGDDNwaRIsClOv22HzCIMX9X64FbvIBNjP2ulPdfCgZw7ekrv7JGAPc30W+0b7rA==",
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.4.0.tgz",
"integrity": "sha512-6OjsEFQIwcRT1nId/O7OyHjDvrE0JSFAef/XCwGarrf9MF5/CaD0lhDvoySbus57dYEdJ3vrRVj5F3i30c4ozw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -46774,7 +46791,6 @@
},
"devDependencies": {
"cross-env": "^10.1.0",
"fs-extra": "^11.4.0",
"jest": "^30.5.1",
"yeoman-test": "^11.8.2"
},
@@ -46875,8 +46891,8 @@
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^4.0.0",
"ace-builds": "^1.44.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"ag-grid-community": "36.2.0",
"ag-grid-react": "36.2.0",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"core-js": "^3.50.0",
@@ -46897,7 +46913,7 @@
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^15.0.0",
"react-draggable": "^4.7.1",
"react-draggable": "^4.7.2",
"react-error-boundary": "^6.1.5",
"react-js-cron": "^6.0.2",
"react-markdown": "^10.1.0",
@@ -46921,7 +46937,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.5.1",
"@types/node": "^26.6.1",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -47012,20 +47028,6 @@
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"packages/superset-ui-core/node_modules/react-draggable": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"packages/superset-ui-core/node_modules/react-resize-detector": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
@@ -47388,7 +47390,7 @@
"@math.gl/web-mercator": "^4.1.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"mapbox-gl": "^3.30.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "^6.8.0",
"react-map-gl": "^8.1.3",
"supercluster": "^9.1.0"

View File

@@ -52,8 +52,8 @@
"cover": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage",
"dev": "webpack --mode=development --color --watch",
"dev-server": "cross-env NODE_ENV=development BABEL_ENV=development node --max_old_space_size=4096 ./node_modules/webpack-dev-server/bin/webpack-dev-server.js --mode=development",
"format": "npm run _format --write",
"format-check": "npm run _format --check",
"format": "npm run _format -- --write",
"format-check": "npm run _format -- --check",
"lint": "npx oxlint --config oxlint.json --quiet",
"lint:all": "npx oxlint --config oxlint.json && npm run type",
"lint-fix": "npx oxlint --config oxlint.json --fix --quiet",
@@ -66,7 +66,6 @@
"ensure-oxc": "echo 'OXC linter is ready' && npx oxlint --version",
"lint-stats": "node ./scripts/oxlint-metrics-uploader.js",
"plugins:build": "node ./scripts/build.js",
"plugins:build-assets": "node ./scripts/copyAssets.js",
"plugins:create-conventional-version": "npm run prune && lerna version --conventional-commits --create-release github --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
"plugins:create-minor-version": "npm run prune && lerna version minor --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
"plugins:create-patch-version": "npm run prune && lerna version patch --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
@@ -123,9 +122,9 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^6.10.0",
"@rjsf/core": "^6.10.1",
"@rjsf/utils": "^6.6.2",
"@rjsf/validator-ajv8": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.1",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -156,8 +155,8 @@
"@visx/scale": "^4.0.0",
"@visx/tooltip": "^4.0.0",
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"ag-grid-community": "36.2.0",
"ag-grid-react": "36.2.0",
"antd": "^6.6.4",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
@@ -167,8 +166,6 @@
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.4.0",
"fuse.js": "^7.5.0",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
@@ -176,7 +173,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.2",
"google-auth-library": "^11.1.0",
"immer": "^11.1.18",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -185,8 +182,8 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.30.0",
"markdown-to-jsx": "^9.10.2",
"mapbox-gl": "^3.31.0",
"markdown-to-jsx": "^9.10.3",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
@@ -281,7 +278,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.5.1",
"@types/node": "^26.6.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -299,7 +296,7 @@
"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.23",
"baseline-browser-mapping": "^2.11.25",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -326,14 +323,14 @@
"jest-html-reporter": "^4.4.2",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^30.0.1",
"jsdom": "^30.1.0",
"lerna": "^10.0.1",
"lightningcss": "^1.33.0",
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.10.1",
"open-cli": "^9.0.0",
"oxfmt": "^0.68.0",
"oxlint": "^1.82.0",
"oxlint": "^1.83.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"process": "^0.11.10",
@@ -354,7 +351,7 @@
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.111.0",
"webpack-bundle-analyzer": "^5.3.3",
"webpack-bundle-analyzer": "^5.4.0",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0",
"webpack-manifest-plugin": "^6.0.1",

View File

@@ -38,7 +38,6 @@
},
"devDependencies": {
"cross-env": "^10.1.0",
"fs-extra": "^11.4.0",
"jest": "^30.5.1",
"yeoman-test": "^11.8.2"
},

View File

@@ -20,8 +20,7 @@
import { dirname, join } from 'path';
// @ts-ignore -- yeoman-test type resolution differs between local and Docker environments
import helpers from 'yeoman-test';
// @ts-ignore -- fs-extra/esm has no type declarations
import { copySync } from 'fs-extra/esm';
import { cpSync } from 'fs';
import { fileURLToPath } from 'url';
import pluginChartModule from '../generators/plugin-chart';
@@ -31,9 +30,10 @@ test('generator-superset:plugin-chart:creates files', async () => {
.onTargetDirectory((dir: string) => {
// `dir` is the path to the new temporary directory
const generatorDirname = dirname(fileURLToPath(import.meta.url));
copySync(
cpSync(
join(generatorDirname, '../generators/plugin-chart/templates'),
join(dir, 'unknown/templates'),
{ recursive: true },
);
})
.withPrompts({

View File

@@ -57,8 +57,8 @@
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^4.0.0",
"ace-builds": "^1.44.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"ag-grid-community": "36.2.0",
"ag-grid-react": "36.2.0",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"core-js": "^3.50.0",
@@ -79,7 +79,7 @@
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^15.0.0",
"react-draggable": "^4.7.1",
"react-draggable": "^4.7.2",
"react-error-boundary": "^6.1.5",
"react-js-cron": "^6.0.2",
"react-markdown": "^10.1.0",
@@ -103,7 +103,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.5.1",
"@types/node": "^26.6.1",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -75,6 +75,28 @@ export const resolveAutoCurrency = (
return null; // Mixed currencies
};
/**
* Resolves an AUTO currency to the backend-detected one when the currency
* code column isn't part of the query results, since CurrencyFormatter can
* only detect AUTO per row from that column.
*/
export const resolveDetectedCurrency = (
currency: Currency | undefined,
detectedCurrency: string | null | undefined,
currencyCodeColumn: string | undefined,
colnames: string[] | undefined,
): Currency | undefined => {
if (
currency?.symbol !== AUTO_CURRENCY_SYMBOL ||
!detectedCurrency ||
(currencyCodeColumn && colnames?.includes(currencyCodeColumn))
) {
return currency;
}
const symbol = normalizeCurrency(detectedCurrency);
return symbol ? { ...currency, symbol } : currency;
};
const getEffectiveCurrencyFormat = (
resolvedCurrencyFormat: Currency | undefined | null,
savedFormat: Currency | undefined,

View File

@@ -30,6 +30,7 @@ import {
import {
analyzeCurrencyInData,
resolveAutoCurrency,
resolveDetectedCurrency,
} from '../../src/currency-format/utils';
test('buildCustomFormatters without saved metrics returns empty object', () => {
@@ -412,3 +413,35 @@ test('getValueFormatter returns NumberFormatter via line 205 when AUTO resolves
);
expect(formatter).toBeInstanceOf(NumberFormatter);
});
test('resolveDetectedCurrency uses the detected currency when the currency column is not in the results', () => {
expect(
resolveDetectedCurrency(
{ symbol: 'AUTO', symbolPosition: 'prefix' },
'gbp',
'currency_code',
['metric'],
),
).toEqual({ symbol: 'GBP', symbolPosition: 'prefix' });
});
test('resolveDetectedCurrency keeps AUTO when the currency column is in the results', () => {
const currency: Currency = { symbol: 'AUTO', symbolPosition: 'prefix' };
expect(
resolveDetectedCurrency(currency, 'GBP', 'currency_code', [
'metric',
'currency_code',
]),
).toBe(currency);
});
test('resolveDetectedCurrency leaves non-AUTO and unresolvable currencies untouched', () => {
const usd: Currency = { symbol: 'USD', symbolPosition: 'prefix' };
const auto: Currency = { symbol: 'AUTO', symbolPosition: 'prefix' };
expect(resolveDetectedCurrency(usd, 'GBP', 'currency_code', [])).toBe(usd);
expect(resolveDetectedCurrency(auto, null, 'currency_code', [])).toBe(auto);
expect(resolveDetectedCurrency(auto, 'nope', 'currency_code', [])).toBe(auto);
expect(resolveDetectedCurrency(undefined, 'GBP', undefined, [])).toBe(
undefined,
);
});

View File

@@ -26,13 +26,6 @@ export default defineConfig({
// Test directory
testDir: './playwright/tests',
// Conditionally ignore experimental tests based on env var
// When INCLUDE_EXPERIMENTAL=true, experimental tests are included
// Otherwise, they are excluded (default for required tests)
testIgnore: process.env.INCLUDE_EXPERIMENTAL
? undefined
: '**/experimental/**',
// Global setup - authenticate once before all tests
globalSetup: './playwright/global-setup.ts',
@@ -94,14 +87,12 @@ export default defineConfig({
// Default project - uses global authentication for speed
// E2E tests login once via global-setup.ts and reuse auth state
// Explicitly ignore auth tests (they run in chromium-unauth project)
// Also respect the global experimental testIgnore setting
name: 'chromium',
testIgnore: [
'**/tests/auth/**/*.spec.ts',
'**/tests/sqllab/**/*.spec.ts',
'**/tests/embedded/**/*.spec.ts',
'**/tests/mobile/**/*.spec.ts',
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
],
use: {
browserName: 'chromium',

View File

@@ -41,7 +41,7 @@ import { TIMEOUT } from '../../utils/constants';
const DATASET_NAME = 'birth_names';
testWithAssets(
'Handlebars formatDate helper renders a formatted date (#32960)',
'Handlebars formatDate helper renders a formatted date',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);

View File

@@ -18,9 +18,6 @@
*/
/**
* E2E migration of the Cypress "Drill to detail modal" suite
* (dashboard/drilltodetail.test.ts).
*
* Drill to detail lets a viewer open a modal of the underlying sample rows for a
* chart — optionally filtered to a single data point — by either the chart's
* "More Options" header menu or a right-click context menu on the chart body.

View File

@@ -53,7 +53,7 @@ const COLOR_INTERVAL_2: [number, number, number] = [69, 78, 124];
const COLOR_UNUSED_3: [number, number, number] = [90, 193, 137];
testWithAssets(
'Gauge renders configured interval colors on a dashboard (#28766)',
'Gauge renders configured interval colors on a dashboard',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);

View File

@@ -53,7 +53,7 @@ const FILTER_COLUMN = 'gender';
const FILTER_VALUE = 'boy';
testWithAssets(
'Mixed chart applies dashboard filter to both queries (#29519)',
'Mixed chart applies dashboard filter to both queries',
async ({ page, testAssets }) => {
// Four API round-trips of setup precede a full dashboard load with a
// preselected native filter, matching the other dashboard specs that build

View File

@@ -82,7 +82,7 @@ async function findDatasetIdByName(page: Page, name: string): Promise<number> {
return body.result[0].id;
}
test.describe('Embedded Pivot Table collapse state (#33406)', () => {
test.describe('Embedded Pivot Table collapse state', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(90000);

View File

@@ -1,123 +0,0 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Experimental Playwright Tests
## Purpose
This directory contains **experimental** Playwright E2E tests that are being developed and stabilized before becoming part of the required test suite.
## How Experimental Tests Work
### Running Tests
**By default (CI and local), experimental tests are EXCLUDED:**
```bash
npm run playwright:test
# Only runs stable tests (tests/auth/*)
```
**To include experimental tests, set the environment variable:**
```bash
INCLUDE_EXPERIMENTAL=true npm run playwright:test
# Runs all tests including experimental/
```
### CI Behavior
- **Required CI jobs**: Experimental tests are excluded by default
- Tests in `experimental/` do NOT block merges
- Failures in `experimental/` do NOT fail the build
- **Experimental CI jobs** (optional): Use `TEST_PATH=experimental/`
- Set `INCLUDE_EXPERIMENTAL=true` in the job environment to include experimental tests
- These jobs can use `continue-on-error: true` for shadow mode
### Configuration
The experimental pattern is configured in `playwright.config.ts`:
```typescript
testIgnore: process.env.INCLUDE_EXPERIMENTAL
? undefined
: '**/experimental/**',
```
This ensures:
- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored
- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental
## When to Use Experimental
Add tests to `experimental/` when:
1. **Testing new infrastructure** - New page objects, components, or patterns that need real-world validation
2. **Flaky tests** - Tests that pass locally but have intermittent CI failures that need investigation
3. **New test types** - E2E tests for new features that need to prove stability before becoming required
4. **Prototyping** - Experimental approaches that may or may not become standard patterns
## Moving Tests to Stable
Once an experimental test has proven stable (consistent CI passes over time):
1. **Move the test file** from `experimental/` to the appropriate stable directory:
```bash
git mv tests/experimental/dataset/my-test.spec.ts tests/dataset/my-test.spec.ts
```
2. **Commit the move** with a clear message:
```bash
git commit -m "test(playwright): promote my-test from experimental to stable"
```
3. **Test will now be required** - It will run by default and block merges on failure
## Current Experimental Tests
### Dataset Tests
- **`dataset/dataset-list.spec.ts`** - Dataset list E2E tests
- Status: Infrastructure complete, validating stability
- Includes: Delete dataset test with API-based test data
- Supporting infrastructure: API helpers, Modal components, page objects
## Infrastructure Location
**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`:
✅ **Correct locations:**
- `playwright/components/` - Components used by any tests
- `playwright/pages/` - Page objects for any features
- `playwright/helpers/api/` - API helpers for test data setup
❌ **Avoid:**
- `playwright/tests/experimental/components/` - Makes it hard to share infrastructure
This keeps infrastructure reusable and avoids duplication when tests graduate from experimental to stable.
## Questions?
See [Superset Testing Documentation](https://superset.apache.org/docs/contributing/development#testing) or ask in the `#testing` Slack channel.

View File

@@ -24,22 +24,18 @@
* backend round-trip; the reuse-vs-fallback and row-limit slicing logic is
* unit-tested in useResultsPane.test.tsx, so this E2E asserts only the
* end-to-end win: opening Results issues no extra chart/data request.
*
* Lives under tests/experimental/ until proven stable in CI; run with:
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
* tests/experimental/explore/results-tab-reuse.spec.ts -- --headed
*/
import { testWithAssets, expect } from '../../../helpers/fixtures';
import { apiPostChart } from '../../../helpers/api/chart';
import { getDatasetByName } from '../../../helpers/api/dataset';
import { ExplorePage } from '../../../pages/ExplorePage';
import { TIMEOUT } from '../../../utils/constants';
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
import { ExplorePage } from '../../pages/ExplorePage';
import { TIMEOUT } from '../../utils/constants';
const DATASET_NAME = 'birth_names';
const CHART_DATA_PATH = '/api/v1/chart/data';
testWithAssets(
'Results tab reuses chart data without a duplicate query (#38165)',
'Results tab reuses chart data without a duplicate query',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);

View File

@@ -29,7 +29,7 @@ import {
getNumberFormatter,
getTimeFormatter,
getTimeFormatterForGranularity,
normalizeCurrency,
resolveDetectedCurrency,
NumberFormats,
QueryMode,
SMART_DATE_ID,
@@ -147,6 +147,7 @@ const getComparisonColFormatter = (
columnConfig: Record<string, TableColumnConfig>,
savedFormat: string | undefined,
savedCurrency: Currency | undefined,
resolveCurrency: (currency: Currency | undefined) => Currency | undefined,
) => {
const currentColConfig = getComparisonColConfig(
label,
@@ -161,7 +162,9 @@ const getComparisonColFormatter = (
if (label === '%') {
formatter = getNumberFormatter(currentColNumberFormat || PERCENT_3_POINT);
} else if (currentColNumberFormat || hasCurrency) {
const currency = currentColConfig.currencyFormat || savedCurrency;
const currency = resolveCurrency(
currentColConfig.currencyFormat || savedCurrency,
);
const numberFormat = currentColNumberFormat || savedFormat;
formatter = currency
? new CurrencyFormatter({
@@ -254,9 +257,19 @@ const processComparisonColumns = (
) =>
columns.flatMap(col => {
const {
datasource: { columnFormats, currencyFormats },
datasource: { columnFormats, currencyFormats, currencyCodeColumn },
rawFormData: { column_config: columnConfig = {} },
queriesData,
} = props;
const { detected_currency: detectedCurrency, colnames } =
queriesData[0] || {};
const resolveCurrency = (currency: Currency | undefined) =>
resolveDetectedCurrency(
currency,
detectedCurrency,
currencyCodeColumn,
colnames,
);
const savedFormat = columnFormats?.[col.key];
const savedCurrency = currencyFormats?.[col.key];
const originalLabel = col.label;
@@ -279,6 +292,7 @@ const processComparisonColumns = (
columnConfig,
savedFormat,
savedCurrency,
resolveCurrency,
),
},
{
@@ -294,6 +308,7 @@ const processComparisonColumns = (
columnConfig,
savedFormat,
savedCurrency,
resolveCurrency,
),
},
{
@@ -309,6 +324,7 @@ const processComparisonColumns = (
columnConfig,
savedFormat,
savedCurrency,
resolveCurrency,
),
},
{
@@ -324,6 +340,7 @@ const processComparisonColumns = (
columnConfig,
savedFormat,
savedCurrency,
resolveCurrency,
),
},
];
@@ -477,21 +494,12 @@ const processColumns = memoizePerChart(function processColumns(
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || (isNumber && (numberFormat || currency))) {
// Resolve AUTO currency when currency column isn't in query results
let resolvedCurrency = currency;
if (
currency?.symbol === 'AUTO' &&
detectedCurrency &&
(!currencyCodeColumn || !colnames?.includes(currencyCodeColumn))
) {
const normalizedCurrency = normalizeCurrency(detectedCurrency);
if (normalizedCurrency) {
resolvedCurrency = {
...currency,
symbol: normalizedCurrency,
};
}
}
const resolvedCurrency = resolveDetectedCurrency(
currency,
detectedCurrency,
currencyCodeColumn,
colnames,
);
formatter = resolvedCurrency?.symbol
? new CurrencyFormatter({
d3Format: numberFormat,

View File

@@ -329,6 +329,51 @@ test('does not mistake the all_records percent-metric query for the totals query
expect(result.totals).toEqual({ sum__num: 42 });
});
test('resolves AUTO currency on comparison columns from detected_currency', () => {
const autoCurrency = { symbol: 'AUTO', symbolPosition: 'prefix' };
const comparisonKeys = ['Main metric_1', '# metric_1', '△ metric_1'];
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
slice_id: 1,
query_mode: QueryMode.Aggregate,
metrics: ['metric_1'],
percent_metrics: [],
column_config: Object.fromEntries(
comparisonKeys.map(key => [key, { currencyFormat: autoCurrency }]),
),
table_timestamp_format: '',
time_compare: ['1 year ago'],
comparison_type: 'values',
},
datasource: {
columns: [],
metrics: [],
columnFormats: {},
currencyFormats: {},
verboseMap: {},
currencyCodeColumn: 'currency_code',
},
queriesData: [
{
data: [{ metric_1: 100, 'metric_1__1 year ago': 80 }],
colnames: ['metric_1', 'metric_1__1 year ago'],
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
rowcount: 1,
detected_currency: 'GBP',
},
{ data: [{ rowcount: 1 }] },
] as unknown as TableChartProps['queriesData'],
} as unknown as Partial<TableChartProps>);
const { columns } = transformProps(props);
comparisonKeys.forEach(key => {
const formatted = columns.find(col => col.key === key)?.formatter?.(100);
expect(formatted).toContain('£');
});
});
test('excludes Green/Red color-scheme rules from columnColorFormatters', () => {
// Green/Red rules are rendered via the increase/decrease path, so they must
// not reach getColorFormatters, which would treat the scheme name as a hex

View File

@@ -241,7 +241,8 @@ test('replaces stale query event handlers without clearing regular event handler
});
test.each([
// Report/thumbnail screenshots render in standalone "true" (charts) or 3 (reports)
// Report/thumbnail captures use standalone=3; "true" is the legacy capture
// value and stays supported for links created before numeric modes existed
['true', true],
['3', true],
// Live embeds use 1/2 and must keep animation

View File

@@ -125,8 +125,9 @@ use([
LegacyGridContainLabel,
]);
// Report/thumbnail screenshots use standalone="true" (charts) or 3 (reports);
// live embeds use 1/2 and keep animation. See superset/utils/screenshots.py.
// Report/thumbnail screenshots use standalone=3. Legacy chart capture URLs may
// still carry standalone="true"; live embeds use 1/2 and keep animation.
// See superset/utils/screenshots.py.
export function isReportScreenshotMode(): boolean {
try {
const standalone = new URLSearchParams(window.location.search).get(

View File

@@ -32,7 +32,7 @@
"@math.gl/web-mercator": "^4.1.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"mapbox-gl": "^3.30.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "^6.8.0",
"react-map-gl": "^8.1.3",
"supercluster": "^9.1.0"

View File

@@ -30,7 +30,7 @@ import {
getTimeFormatter,
getTimeFormatterForGranularity,
isAdhocColumn,
normalizeCurrency,
resolveDetectedCurrency,
NumberFormats,
QueryMode,
SMART_DATE_ID,
@@ -299,21 +299,12 @@ const processColumns = memoizeOne(function processColumns(
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || (isNumber && (numberFormat || currency))) {
// Resolve AUTO currency when currency column isn't in query results
let resolvedCurrency = currency;
if (
currency?.symbol === 'AUTO' &&
detectedCurrency &&
(!currencyCodeColumn || !colnames?.includes(currencyCodeColumn))
) {
const normalizedCurrency = normalizeCurrency(detectedCurrency);
if (normalizedCurrency) {
resolvedCurrency = {
...currency,
symbol: normalizedCurrency,
};
}
}
const resolvedCurrency = resolveDetectedCurrency(
currency,
detectedCurrency,
currencyCodeColumn,
colnames,
);
formatter = resolvedCurrency?.symbol
? new CurrencyFormatter({
d3Format: numberFormat,
@@ -358,6 +349,7 @@ const getComparisonColFormatter = (
columnConfig: Record<string, TableColumnConfig>,
savedFormat: string | undefined,
savedCurrency: Currency | undefined,
resolveCurrency: (currency: Currency | undefined) => Currency | undefined,
) => {
const currentColConfig = getComparisonColConfig(
label,
@@ -372,7 +364,9 @@ const getComparisonColFormatter = (
if (label === '%') {
formatter = getNumberFormatter(currentColNumberFormat || PERCENT_3_POINT);
} else if (currentColNumberFormat || hasCurrency) {
const currency = currentColConfig.currencyFormat || savedCurrency;
const currency = resolveCurrency(
currentColConfig.currencyFormat || savedCurrency,
);
const numberFormat = currentColNumberFormat || savedFormat;
formatter = currency
? new CurrencyFormatter({
@@ -391,9 +385,19 @@ const processComparisonColumns = (
) =>
columns.flatMap(col => {
const {
datasource: { columnFormats, currencyFormats },
datasource: { columnFormats, currencyFormats, currencyCodeColumn },
rawFormData: { column_config: columnConfig = {} },
queriesData,
} = props;
const { detected_currency: detectedCurrency, colnames } =
queriesData[0] || {};
const resolveCurrency = (currency: Currency | undefined) =>
resolveDetectedCurrency(
currency,
detectedCurrency,
currencyCodeColumn,
colnames,
);
const savedFormat = columnFormats?.[col.key];
const savedCurrency = currencyFormats?.[col.key];
const originalLabel = col.label;
@@ -415,6 +419,7 @@ const processComparisonColumns = (
columnConfig,
savedFormat,
savedCurrency,
resolveCurrency,
),
},
{
@@ -429,6 +434,7 @@ const processComparisonColumns = (
columnConfig,
savedFormat,
savedCurrency,
resolveCurrency,
),
},
{
@@ -443,6 +449,7 @@ const processComparisonColumns = (
columnConfig,
savedFormat,
savedCurrency,
resolveCurrency,
),
},
{
@@ -457,6 +464,7 @@ const processComparisonColumns = (
columnConfig,
savedFormat,
savedCurrency,
resolveCurrency,
),
},
];

View File

@@ -335,6 +335,38 @@ describe('plugin-chart-table', () => {
expect(formattedPercentMetric).toBe('0.123');
});
test('resolves AUTO currency on comparison columns from detected_currency', () => {
const autoCurrency = { symbol: 'AUTO', symbolPosition: 'prefix' };
const comparisonKeys = ['Main metric_1', '# metric_1', '△ metric_1'];
const transformedProps = transformProps({
...testData.comparisonWithConfig,
rawFormData: {
...testData.comparisonWithConfig.rawFormData,
column_config: Object.fromEntries(
comparisonKeys.map(key => [key, { currencyFormat: autoCurrency }]),
),
},
datasource: {
...testData.comparisonWithConfig.datasource,
currencyCodeColumn: 'currency_code',
},
queriesData: [
{
...testData.comparisonWithConfig.queriesData[0],
detected_currency: 'GBP',
},
testData.comparisonWithConfig.queriesData[1],
],
});
comparisonKeys.forEach(key => {
const formatted = transformedProps.columns
.find(col => col.key === key)
?.formatter?.(100);
expect(formatted).toContain('£');
});
});
test('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
const transformedProps = transformProps(testData.comparison);

View File

@@ -25,7 +25,7 @@
*/
import { spawnSync } from 'node:child_process';
import fastGlob from 'fast-glob';
import { globSync } from 'node:fs';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
@@ -61,26 +61,29 @@ function getPackages(packagePattern, tsOnly = false) {
// Find packages in both @superset-ui and @apache-superset scopes
const supersetUiPackages = [
...new Set(
fastGlob
.sync([
globSync(
[
`./node_modules/@superset-ui/${pattern}/src/**/*.${
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
}`,
])
.map(x => x.split('/')[3])
],
{ followSymlinks: true },
)
.map(x => x.split('/')[2])
.filter(x => !META_PACKAGES.has(x)),
),
];
const apachePackages = [
...new Set(
fastGlob
.sync([
globSync(
[
`./node_modules/@apache-superset/${pattern}/src/**/*.${
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
}`,
])
.map(x => x.split('/')[3]),
],
{ followSymlinks: true },
).map(x => x.split('/')[2]),
),
];

View File

@@ -1,56 +0,0 @@
#!/usr/bin/env node
/*
* 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.
*/
/* eslint-disable no-console */
import { sync } from 'fast-glob';
import { copy } from 'fs-extra';
const pkgGlob = process.argv[2] || '*';
const packages = sync([`{packages,plugins}/${pkgGlob}`], {
onlyDirectories: true,
});
console.log('Copying asset files from package {src} to {lib,esm}...');
packages.forEach(pkg => {
const assets = sync([`${pkg}/src/**/*.{png,gif,jpg,css,geojson}`]);
assets.forEach(filePath => {
['lib', 'esm']
.map(dir => filePath.replace(`${pkg}/src`, `${pkg}/${dir}`))
.forEach(newFilePath => {
copy(filePath, newFilePath, err => {
if (err) {
console.error(err);
}
});
});
});
if (assets.length > 0) {
console.log(
` Copied ${assets.length
.toString()
.padStart(2)} asset files for ${pkg.replace(
'packages/superset-ui-',
'',
)}`,
);
}
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { type ComponentType, useRef } from 'react';
import { type ComponentType, type ReactNode, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme } from '@apache-superset/core/theme';
@@ -88,7 +88,7 @@ export const ChatPanelHost = () => {
* floating panel overlay. The trigger is always visible when a chat is
* registered; the panel overlay is suppressed in panel mode.
*/
export const ChatFloatingHost = () => {
export const ChatFloatingHost = ({ children }: { children?: ReactNode }) => {
const theme = useTheme();
const { open: panelOpen, mode, chat, trigger, panel } = useChat();
const onError = useCrashNotifier(chat?.id);
@@ -116,13 +116,15 @@ export const ChatFloatingHost = () => {
Separate boundaries so a crashing panel cannot take the trigger down
with it — the trigger is the user's only way back.
*/}
{panelOpen && mode !== 'panel' && (
<ChatBoundary
key={`panel-${chat.id}`}
component={panel}
onError={onError}
/>
)}
{panelOpen &&
mode !== 'panel' &&
(children ?? (
<ChatBoundary
key={`panel-${chat.id}`}
component={panel}
onError={onError}
/>
))}
<ChatBoundary
key={`trigger-${chat.id}`}
component={trigger}

View File

@@ -23,7 +23,10 @@ import {
saveChartCustomization,
} from './chartCustomizationActions';
import { SET_IN_SCOPE_STATUS_OF_FILTERS } from './nativeFilters';
import { DASHBOARD_INFO_UPDATED } from './dashboardInfo';
import {
DASHBOARD_INFO_UPDATED,
dashboardSaveSucceeded,
} from './dashboardInfo';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
@@ -225,6 +228,8 @@ test('saveChartCustomization filters null entries from currentConfig before merg
const thunk = saveChartCustomization([customization], [], [], false);
await thunk(dispatch, getState, null);
expect(dispatch).toHaveBeenCalledWith(dashboardSaveSucceeded(1));
// DASHBOARD_INFO_UPDATED should have merged config without nulls
const infoUpdateCall = dispatch.mock.calls.find(
([action]: [{ type: string }]) => action.type === DASHBOARD_INFO_UPDATED,
@@ -271,3 +276,16 @@ test('saveChartCustomization filters null entries from oldConfig when resetDataM
// Should not throw when building oldCustomizationsById from null-containing config
await expect(thunk(dispatch, getState, null)).resolves.toBeDefined();
});
test('failed customization persistence does not signal a successful dashboard save', async () => {
fetchMock.put('glob:*/api/v1/dashboard/1/chart_customizations', 500);
const { getState, dispatch } = setup({
dashboardInfo: { id: 1, metadata: {} },
});
await expect(
saveChartCustomization([], [])(dispatch, getState, null),
).rejects.toBeDefined();
expect(dispatch).not.toHaveBeenCalledWith(dashboardSaveSucceeded(1));
});

View File

@@ -34,7 +34,7 @@ import {
removeDataMask,
setDataMaskForFilterChangesComplete,
} from 'src/dataMask/actions';
import { dashboardInfoChanged } from './dashboardInfo';
import { dashboardInfoChanged, dashboardSaveSucceeded } from './dashboardInfo';
import {
SET_NATIVE_FILTERS_CONFIG_COMPLETE,
SET_IN_SCOPE_STATUS_OF_FILTERS,
@@ -106,6 +106,8 @@ export function saveChartCustomization(
reordered: reorderedIds,
});
dispatch(dashboardSaveSucceeded(id));
const currentMetadata = getState().dashboardInfo.metadata;
const currentConfig =
currentMetadata?.chart_customization_config?.filter(Boolean) || [];

View File

@@ -38,6 +38,12 @@ const createUpdateDashboardApi = (id: number) =>
endpoint: `/api/v1/dashboard/${id}`,
});
export const DASHBOARD_SAVE_SUCCEEDED = 'DASHBOARD_SAVE_SUCCEEDED';
export function dashboardSaveSucceeded(dashboardId: number) {
return { type: DASHBOARD_SAVE_SUCCEEDED, dashboardId };
}
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
export const DASHBOARD_INFO_FILTERS_CHANGED = 'DASHBOARD_INFO_FILTERS_CHANGED';

View File

@@ -18,11 +18,15 @@
*/
import { HYDRATE_DASHBOARD, hydrateDashboard } from './hydrate';
import {
CHART_TYPE,
COLUMN_TYPE,
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
ROW_TYPE,
TABS_TYPE,
TAB_TYPE,
} from '../util/componentTypes';
import { DASHBOARD_ROOT_ID } from '../util/constants';
import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_ID } from '../util/constants';
/**
* Regression guard for the follow-up to PR #39417 / PR #41832: the default
@@ -274,3 +278,82 @@ test('a permalink activeTabs: [] (empty but present) wins and seeds []', () => {
expect(action.data.dashboardState.activeTabs).toEqual([]);
});
test('keeps a trapped chart missing from the charts payload in the layout', () => {
// e.g. an archived chart with SOFT_DELETE: the charts endpoint omits it, so
// hydration cannot re-add it and only its layout entry keeps its membership
const positionData = {
[DASHBOARD_ROOT_ID]: layoutItem(
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_TYPE,
[DASHBOARD_GRID_ID],
[],
),
[DASHBOARD_GRID_ID]: layoutItem(
DASHBOARD_GRID_ID,
DASHBOARD_GRID_TYPE,
[],
[DASHBOARD_ROOT_ID],
),
'COLUMN-orphan': layoutItem(
'COLUMN-orphan',
COLUMN_TYPE,
['CHART-archived', 'ROW-orphan'],
[DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID],
),
'ROW-orphan': layoutItem(
'ROW-orphan',
ROW_TYPE,
['COLUMN-orphan'],
[DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, 'COLUMN-orphan'],
),
'CHART-archived': {
...layoutItem('CHART-archived', CHART_TYPE, [], []),
meta: { chartId: 42, width: 4, height: 50 },
},
};
const layout = hydrate(positionData).data.dashboardLayout.present;
expect(layout['COLUMN-orphan']).toBeUndefined();
expect(layout['ROW-orphan']).toBeUndefined();
const [rowId] = layout[DASHBOARD_GRID_ID].children;
expect(layout[rowId].children).toEqual(['CHART-archived']);
expect(layout['CHART-archived'].meta.chartId).toBe(42);
});
test('rebuilds stale parents from the layout children', () => {
const positionData = {
[DASHBOARD_ROOT_ID]: layoutItem(
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_TYPE,
[DASHBOARD_GRID_ID],
[],
),
[DASHBOARD_GRID_ID]: layoutItem(
DASHBOARD_GRID_ID,
DASHBOARD_GRID_TYPE,
['ROW-a'],
[DASHBOARD_ROOT_ID],
),
'ROW-a': layoutItem('ROW-a', ROW_TYPE, ['COLUMN-a'], [DASHBOARD_ROOT_ID]),
'COLUMN-a': layoutItem(
'COLUMN-a',
COLUMN_TYPE,
[],
[DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, 'ROW-stale'],
),
};
const layout = hydrate(positionData).data.dashboardLayout.present;
expect(layout['ROW-a'].parents).toEqual([
DASHBOARD_ROOT_ID,
DASHBOARD_GRID_ID,
]);
expect(layout['COLUMN-a'].parents).toEqual([
DASHBOARD_ROOT_ID,
DASHBOARD_GRID_ID,
'ROW-a',
]);
});

View File

@@ -55,6 +55,7 @@ import getLocationHash from 'src/dashboard/util/getLocationHash';
import newComponentFactory, {
DashboardEntity,
} from 'src/dashboard/util/newComponentFactory';
import removeUnreachableComponents from 'src/dashboard/util/removeUnreachableComponents';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
@@ -133,11 +134,13 @@ export const hydrateDashboard =
// new dash: position_json could be {} or null
// getEmptyLayout() includes a version string entry plus BasicLayoutItem entries
// which lack the `meta` field; layout is mutated below to add full LayoutItem entries
const layout = (
positionData && Object.keys(positionData).length > 0
// Repaired before anything indexes the layout: a detached cycle crashes the
// filter scope modal, and a chart trapped in one would never render.
const layout = removeUnreachableComponents(
(positionData && Object.keys(positionData).length > 0
? positionData
: getEmptyLayout()
) as Record<string, LayoutItem | DashboardEntity>;
: getEmptyLayout()) as Record<string, LayoutItem | DashboardEntity>,
);
// create a lookup to sync layout names with slice names
const chartIdToLayoutId: Record<number, string> = {};
@@ -241,17 +244,12 @@ export const hydrateDashboard =
}
});
// make sure that parents tree is built
if (
Object.values(layout).some(
element => element.id !== DASHBOARD_ROOT_ID && !element.parents,
)
) {
updateComponentParentsList({
currentComponent: layout[DASHBOARD_ROOT_ID] as LayoutItem,
layout: layout as Record<string, LayoutItem>,
});
}
// buildActiveFilters reads `parents` for filter scopes before the layout
// reducer rebuilds them, and the repair above may have moved components
updateComponentParentsList({
currentComponent: layout[DASHBOARD_ROOT_ID] as LayoutItem,
layout: layout as Record<string, LayoutItem>,
});
buildActiveFilters({
dashboardFilters: dashboardFilters as Parameters<

View File

@@ -16,12 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { StrictMode, useState } from 'react';
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import { storeWithState } from 'spec/fixtures/mockStore';
import mockState from 'spec/fixtures/mockState';
import { sliceId } from 'spec/fixtures/mockChartQueries';
import { ChartCustomizationType, NativeFilterType } from '@superset-ui/core';
import {
ChartCustomizationType,
NativeFilterType,
CategoricalColorNamespace,
} from '@superset-ui/core';
import DashboardGrid from 'src/dashboard/containers/DashboardGrid';
import { applyDashboardLabelsColorOnLoad } from 'src/dashboard/actions/dashboardState';
import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
import { CHART_TYPE } from '../../util/componentTypes';
import DashboardContainer from './DashboardContainer';
import * as nativeFiltersActions from '../../actions/nativeFilters';
@@ -37,7 +45,7 @@ jest.mock('@visx/responsive', () => ({
jest.mock('src/dashboard/containers/DashboardGrid', () => ({
__esModule: true,
default: () => <div data-test="mock-dashboard-grid" />,
default: jest.fn(() => <div data-test="mock-dashboard-grid" />),
}));
// DashboardContainer dispatches these on mount, so unit tests stub them.
@@ -154,6 +162,12 @@ beforeEach(() => {
afterEach(() => {
setInScopeStatusMock.mockRestore();
jest
.mocked(applyDashboardLabelsColorOnLoad)
.mockImplementation(() => async () => {});
jest
.mocked(DashboardGrid)
.mockImplementation(() => <div data-test="mock-dashboard-grid" />);
});
test('calculates chartsInScope correctly for filters', async () => {
@@ -853,3 +867,128 @@ test('does not dispatch setInScopeStatusOfCustomizations when chart_customizatio
spy.mockRestore();
}
});
test.each([false, true])(
'applies custom label colors before a cached chart consumes its first color scale (StrictMode=%s)',
strict => {
const namespace = 'initial-label-colors';
CategoricalColorNamespace.getNamespace(namespace).resetColors();
jest
.mocked(applyDashboardLabelsColorOnLoad)
.mockImplementation(
jest.requireActual('src/dashboard/actions/dashboardState')
.applyDashboardLabelsColorOnLoad,
);
jest.mocked(DashboardGrid).mockImplementation(function CachedChart() {
const [color] = useState(() =>
CategoricalColorNamespace.getScale(undefined, namespace).getColor(
'20_Passed',
),
);
return <div data-test="cached-chart" data-color={color} />;
});
const initialState = createTestState({
dashboardInfo: {
...mockState.dashboardInfo,
metadata: {
...mockState.dashboardInfo.metadata,
color_namespace: namespace,
color_scheme: '',
label_colors: { '20_Passed': '#008000' },
},
},
});
const { unmount } = render(
strict ? (
<StrictMode>
<DashboardContainer />
</StrictMode>
) : (
<DashboardContainer />
),
{ useRedux: true, store: storeWithState(initialState) },
);
expect(screen.getByTestId('cached-chart')).toHaveAttribute(
'data-color',
'#008000',
);
unmount();
CategoricalColorNamespace.getNamespace(namespace).resetColors();
},
);
test.each([false, true])(
'initializes the next dashboard colors before mounting its cached charts (new namespace=%s)',
newNamespace => {
const namespace = 'navigation-label-colors';
let chartNamespace = namespace;
CategoricalColorNamespace.getNamespace(namespace).resetColors();
jest
.mocked(applyDashboardLabelsColorOnLoad)
.mockImplementation(
jest.requireActual('src/dashboard/actions/dashboardState')
.applyDashboardLabelsColorOnLoad,
);
jest.mocked(DashboardGrid).mockImplementation(function CachedChart() {
const [color] = useState(() =>
CategoricalColorNamespace.getScale(undefined, chartNamespace).getColor(
'20_Passed',
),
);
return <div data-test="cached-chart" data-color={color} />;
});
const metadata = {
...mockState.dashboardInfo.metadata,
color_namespace: namespace,
color_scheme: '',
label_colors: { '20_Passed': '#008000' },
};
const { store, unmount } = setupWithStore({
dashboardInfo: { ...mockState.dashboardInfo, metadata },
});
expect(screen.getByTestId('cached-chart')).toHaveAttribute(
'data-color',
'#008000',
);
chartNamespace = newNamespace ? 'next-dashboard-colors' : namespace;
act(() => {
store.dispatch(
dashboardInfoChanged({
id: mockState.dashboardInfo.id + 1,
metadata: {
...store.getState().dashboardInfo.metadata,
...metadata,
color_namespace: chartNamespace,
label_colors: { '20_Passed': '#ff0000' },
},
}),
);
});
expect(screen.getByTestId('cached-chart')).toHaveAttribute(
'data-color',
'#ff0000',
);
unmount();
CategoricalColorNamespace.getNamespace(namespace).resetColors();
CategoricalColorNamespace.getNamespace(chartNamespace).resetColors();
},
);
test('waits for dashboard hydration before mounting cached charts', () => {
jest.mocked(applyDashboardLabelsColorOnLoad).mockClear();
const { store } = setupWithStore({ dashboardInfo: {} });
expect(screen.queryByTestId('mock-dashboard-grid')).not.toBeInTheDocument();
expect(applyDashboardLabelsColorOnLoad).not.toHaveBeenCalled();
act(() => {
store.dispatch(dashboardInfoChanged({ id: mockState.dashboardInfo.id }));
});
expect(applyDashboardLabelsColorOnLoad).toHaveBeenCalled();
expect(screen.getByTestId('mock-dashboard-grid')).toBeInTheDocument();
});

View File

@@ -164,8 +164,10 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const renderedChartIds = useRenderedChartIds();
const [dashboardLabelsColorInitiated, setDashboardLabelsColorInitiated] =
useState(false);
const [colorInitializedDashboardId, setColorInitializedDashboardId] =
useState<number | null>(null);
const dashboardLabelsColorInitiated =
colorInitializedDashboardId === dashboardInfo?.id;
const prevRenderedChartIds = useRef<number[]>([]);
const prevTabIndexRef = useRef<number>();
const prevFilterScopesRef = useRef<FilterScopeData[]>([]);
@@ -304,7 +306,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
if (dashboardInfo?.id && !dashboardLabelsColorInitiated) {
dispatch(applyDashboardLabelsColorOnLoad(dashboardInfo.metadata));
// apply labels color as dictated by stored metadata (if any)
setDashboardLabelsColorInitiated(true);
setColorInitializedDashboardId(dashboardInfo.id);
}
return () => {
@@ -379,7 +381,9 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
return (
<div className="grid-container" data-test="grid-container" ref={parentRef}>
{renderParentSizeChildren({ width })}
{/* Defer the grid until hydration and color initialization complete,
before cached charts consume their color scales on first render. */}
{dashboardLabelsColorInitiated && renderParentSizeChildren({ width })}
</div>
);
};

View File

@@ -36,6 +36,11 @@ import { UPDATE_COMPONENTS } from '../../actions/dashboardLayout';
import { AutoRefreshStatus } from '../../types/autoRefresh';
const mockHistoryReplace = jest.fn();
// Dashboards render top-level (not iframed) unless a test says otherwise.
const mockIsInIframe = jest.fn(() => false);
jest.mock('src/dashboard/util/isEmbedded', () => ({
isEmbedded: () => mockIsInIframe(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
@@ -244,6 +249,7 @@ beforeAll(() => {
beforeEach(() => {
jest.clearAllMocks();
mockIsInIframe.mockReturnValue(false);
const { useLocation } = jest.requireMock('react-router-dom');
useLocation.mockReturnValue({
pathname: '/dashboard',
@@ -863,21 +869,44 @@ test('should hide edit button and navbar, and show Exit fullscreen when in fulls
expect(screen.queryByTestId('main-navigation')).not.toBeInTheDocument();
});
test('should show Exit fullscreen when in fullscreen mode', async () => {
setup();
await userEvent.click(screen.getByTestId('actions-trigger'));
expect(await screen.findByText('Exit fullscreen')).toBeInTheDocument();
});
test('should have fullscreen option in dropdown', async () => {
test('should show Exit fullscreen when in standalone mode at top level', async () => {
// Default setup URL carries standalone=1. A user who clicked "Enter fullscreen"
// must be able to get back out, so this must not be hidden outside an iframe.
setup();
await openActionsDropdown();
expect(screen.getByText('Exit fullscreen')).toBeInTheDocument();
expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
});
test('should show Enter fullscreen when not in standalone mode', async () => {
// Keep the router location and window.location in agreement. The shared mock
// reports `?standalone=1`; leaving it would let this test pass on a stale
// premise if the component ever read `location.search` instead of
// `window.location.search`.
const { useLocation } = jest.requireMock('react-router-dom');
useLocation.mockReturnValue({
pathname: '/dashboard',
search: '',
hash: '',
state: undefined,
});
window.history.pushState({}, 'Test page', '/dashboard');
setup();
await openActionsDropdown();
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
expect(screen.queryByText('Exit fullscreen')).not.toBeInTheDocument();
});
test('should hide the fullscreen toggle entirely inside an iframe', async () => {
// Exiting inside an iframe reloads without the standalone param and restores the
// full Superset nav, breaking the embed — so neither direction is offered.
mockIsInIframe.mockReturnValue(true);
setup();
await openActionsDropdown();
expect(screen.queryByText('Exit fullscreen')).not.toBeInTheDocument();
expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
});
test('should render MetadataBar when not in edit mode and not embedded', () => {
const state = {
dashboardInfo: {

View File

@@ -34,6 +34,7 @@ import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants';
import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal';
import getDashboardUrl from 'src/dashboard/util/getDashboardUrl';
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { isEmbedded as isInIframe } from 'src/dashboard/util/isEmbedded';
import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
@@ -290,8 +291,13 @@ export const useHeaderActionsMenu = ({
});
}
// Toggle fullscreen (hide on mobile)
if (!editMode && !isEmbedded && !isMobile) {
// Toggle fullscreen (hide on mobile). Hidden entirely inside an iframe: there,
// "Exit fullscreen" reloads without the standalone param and brings back the full
// Superset nav, breaking the embed. Note `isEmbedded` above is
// `!dashboardInfo.userId` (anonymous guest via the embedded SDK), which does not
// cover an authenticated user whose dashboard is iframed, hence the separate check.
// Top-level users keep both directions so fullscreen is not a one-way door.
if (!editMode && !isEmbedded && !isMobile && !isInIframe()) {
menuItems.push({
key: MenuKeys.ToggleFullscreen,
label: getUrlParam(URL_PARAMS.standalone)

View File

@@ -23,11 +23,15 @@ import { ReactNode } from 'react';
import { LOG_ACTIONS_FORCE_REFRESH_DASHBOARD } from 'src/logger/LogUtils';
import { useHeaderAutoRefresh } from './useHeaderAutoRefresh';
const mockStartAutoRefresh = jest.fn();
const mockEndAutoRefresh = jest.fn();
const mockSetRefreshInFlight = jest.fn();
jest.mock('src/dashboard/contexts/AutoRefreshContext', () => ({
useAutoRefreshContext: () => ({
startAutoRefresh: jest.fn(),
endAutoRefresh: jest.fn(),
setRefreshInFlight: jest.fn(),
startAutoRefresh: mockStartAutoRefresh,
endAutoRefresh: mockEndAutoRefresh,
setRefreshInFlight: mockSetRefreshInFlight,
}),
}));
@@ -45,8 +49,10 @@ jest.mock('src/dashboard/hooks/useRealTimeDashboard', () => ({
}),
}));
const mockUseAutoRefreshTabPause = jest.fn();
jest.mock('src/dashboard/hooks/useAutoRefreshTabPause', () => ({
useAutoRefreshTabPause: jest.fn(),
useAutoRefreshTabPause: (...args: unknown[]) =>
mockUseAutoRefreshTabPause(...args),
}));
const createWrapper = (conf: Record<string, unknown> = {}) => {
@@ -54,6 +60,8 @@ const createWrapper = (conf: Record<string, unknown> = {}) => {
charts: {
1: { latestQueryFormData: { datasource: '1__table' } },
2: { latestQueryFormData: { datasource: '2__table' } },
// A chart on a tab that has never been visited has no query data yet.
3: { latestQueryFormData: {} },
},
dashboardInfo: {
common: { conf },
@@ -152,3 +160,45 @@ test('forceRefresh normalizes a negative config value to 0 (unstaggered)', async
expect.objectContaining({ interval: 0 }),
);
});
test('a silent refresh reports only the affected chart ids to startAutoRefresh, not the whole dashboard', async () => {
mockStartAutoRefresh.mockClear();
mockUseAutoRefreshTabPause.mockClear();
const { props } = renderHeaderAutoRefresh(
{},
{ chartIds: [1, 2], timedRefreshImmuneSlices: [2] },
);
const { onRefresh: handleTabVisibilityRefresh } =
mockUseAutoRefreshTabPause.mock.calls[0][0];
await act(async () => {
await handleTabVisibilityRefresh();
});
expect(props.onRefresh).toHaveBeenCalledTimes(1);
expect(mockStartAutoRefresh).toHaveBeenCalledWith([1]);
expect(mockStartAutoRefresh).not.toHaveBeenCalledWith([1, 2]);
});
test('a silent refresh excludes charts with no previous query data from both startAutoRefresh and onRefresh', async () => {
mockStartAutoRefresh.mockClear();
mockUseAutoRefreshTabPause.mockClear();
const { props } = renderHeaderAutoRefresh(
{},
{ chartIds: [1, 2, 3], timedRefreshImmuneSlices: [2] },
);
const { onRefresh: handleTabVisibilityRefresh } =
mockUseAutoRefreshTabPause.mock.calls[0][0];
await act(async () => {
await handleTabVisibilityRefresh();
});
expect(mockStartAutoRefresh).toHaveBeenCalledTimes(1);
expect(mockStartAutoRefresh).toHaveBeenCalledWith([1]);
expect(props.onRefresh).toHaveBeenCalledTimes(1);
const [refreshedChartIds] = props.onRefresh.mock.calls[0];
expect(refreshedChartIds).toEqual([1]);
});

View File

@@ -158,7 +158,7 @@ export const useHeaderAutoRefresh = ({
}
if (suppressSpinners) {
startAutoRefresh();
startAutoRefresh(chartsToRefresh);
setStatus(AutoRefreshStatus.Fetching);
setFetchStartTime(Date.now());
}

View File

@@ -28,6 +28,7 @@ import * as ColorSchemeSelect from 'src/dashboard/components/ColorSchemeSelect';
import * as SupersetCore from '@superset-ui/core';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import PropertiesModal from '.';
import * as dashboardInfoActions from 'src/dashboard/actions/dashboardInfo';
// Increase timeout for CI environment
jest.setTimeout(60000);
@@ -529,6 +530,7 @@ describe('PropertiesModal', () => {
});
test('preserves certification fields on save without opening Certification section', async () => {
const saved = jest.spyOn(dashboardInfoActions, 'dashboardSaveSucceeded');
// Accordion Collapse only mounts the active panel, so certifiedBy /
// certificationDetails FormItems are unregistered until Certification is
// opened. onFinish must use getFieldsValue(true) to read store values for
@@ -571,6 +573,8 @@ describe('PropertiesModal', () => {
expect(submitCall.certifiedBy).toBe('John Doe');
expect(submitCall.certificationDetails).toBe('Sample certification');
expect(saved).toHaveBeenCalledTimes(1);
expect(saved).toHaveBeenCalledWith(props.dashboardId);
expect(put).toHaveBeenCalled();
const putRequest = put.mock.calls[0][0];
expect(typeof putRequest.body).toBe('string');
@@ -580,6 +584,7 @@ describe('PropertiesModal', () => {
});
test('submitting with onlyApply:true', async () => {
const saved = jest.spyOn(dashboardInfoActions, 'dashboardSaveSucceeded');
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
props.onlyApply = true;
@@ -607,6 +612,7 @@ describe('PropertiesModal', () => {
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledTimes(1);
});
expect(saved).not.toHaveBeenCalled();
});
test('passes full theme object with json_data to onSubmit when theme is selected', async () => {

View File

@@ -55,7 +55,10 @@ import {
setColorScheme,
setDashboardMetadata,
} from 'src/dashboard/actions/dashboardState';
import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
import {
dashboardInfoChanged,
dashboardSaveSucceeded,
} from 'src/dashboard/actions/dashboardInfo';
import { areObjectsEqual } from 'src/reduxUtils';
import { AsyncModeOverride } from 'src/utils/asyncMode';
import { StandardModal, useModalValidation } from 'src/components/Modal';
@@ -477,6 +480,7 @@ const PropertiesModal = ({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(saveData),
}).then(() => {
dispatch(dashboardSaveSucceeded(dashboardId));
onSubmit(onSubmitProps);
onHide();
addSuccessToast(t('The dashboard has been saved'));

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { act, fireEvent, render } from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core';
import * as redux from 'redux';
@@ -27,6 +28,10 @@ import mockDatasource from 'spec/fixtures/mockDatasource';
import chartQueries, {
sliceId as queryId,
} from 'spec/fixtures/mockChartQueries';
import {
AutoRefreshProvider,
useAutoRefreshContext,
} from 'src/dashboard/contexts/AutoRefreshContext';
import Chart from './Chart';
let capturedChartContainerProps: Record<string, unknown> = {};
@@ -109,6 +114,32 @@ function setup(
});
}
function StartAutoRefreshFor({ chartIds }: { chartIds: number[] }) {
const { startAutoRefresh } = useAutoRefreshContext();
useEffect(() => {
startAutoRefresh(chartIds);
}, [chartIds, startAutoRefresh]);
return null;
}
function setupDuringUnrelatedAutoRefresh(
refreshingChartIds: number[],
overrideProps: Record<string, unknown> = {},
overrideState: Record<string, unknown> = {},
) {
return render(
<AutoRefreshProvider>
<StartAutoRefreshFor chartIds={refreshingChartIds} />
<Chart {...props} {...overrideProps} />
</AutoRefreshProvider>,
{
useRedux: true,
useRouter: true,
initialState: { ...defaultState, ...overrideState },
},
);
}
const refreshChart = jest.fn();
const logEvent = jest.fn();
const changeFilter = jest.fn();
@@ -134,6 +165,34 @@ afterEach(() => {
jest.clearAllMocks();
});
test('shows the loading spinner for a chart that starts loading outside the in-flight auto-refresh batch', () => {
setupDuringUnrelatedAutoRefresh([queryId + 1], undefined, {
charts: {
...defaultState.charts,
[queryId]: {
...defaultState.charts[queryId],
chartStatus: 'loading',
},
},
});
expect(capturedChartContainerProps.suppressLoadingSpinner).toBe(false);
});
test('suppresses the loading spinner for a chart included in the in-flight auto-refresh batch', () => {
setupDuringUnrelatedAutoRefresh([queryId], undefined, {
charts: {
...defaultState.charts,
[queryId]: {
...defaultState.charts[queryId],
chartStatus: 'loading',
},
},
});
expect(capturedChartContainerProps.suppressLoadingSpinner).toBe(true);
});
test('should render a SliceHeader', () => {
const { getByTestId, container } = setup();
expect(getByTestId('slice-header')).toBeInTheDocument();

View File

@@ -57,7 +57,7 @@ import {
convertChartStateToOwnState,
hasChartStateConverter,
} from '../../../util/chartStateConverter';
import { useIsAutoRefreshing } from 'src/dashboard/contexts/AutoRefreshContext';
import { useIsChartAutoRefreshing } from 'src/dashboard/contexts/AutoRefreshContext';
import SliceHeader from '../../SliceHeader';
import MissingChart from '../../MissingChart';
@@ -254,7 +254,7 @@ const Chart = (props: ChartProps) => {
(state.dashboardInfo?.metadata as JsonObject)?.show_chart_timestamps ??
false,
);
const suppressLoadingSpinner = useIsAutoRefreshing();
const suppressLoadingSpinner = useIsChartAutoRefreshing(props.id);
const isCached: boolean[] = useMemo(
() =>

View File

@@ -65,11 +65,20 @@ jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
Droppable: ({
children,
depth,
style,
className,
}: {
children: (args: object) => React.ReactNode;
depth: number;
style?: React.CSSProperties;
className?: string;
}) => (
<div data-test="mock-droppable" data-depth={depth}>
<div
data-test="mock-droppable"
data-depth={depth}
className={className}
style={style}
>
{children({})}
</div>
),
@@ -269,6 +278,38 @@ test('should increment the depth of its children', () => {
);
});
test('row droptargets size via CSS instead of a measured pixel height that can only grow (regression for #37644)', () => {
const { container } = setup({ editMode: true });
const getDroptargetHeights = () =>
Array.from(
container.querySelectorAll<HTMLElement>('.empty-droptarget--vertical'),
).map(el => el.style.height);
// The leading droptarget (index 0) is absolutely positioned, so a
// percentage height resolves fine and stretches it to the row via CSS
// rather than a pixel value measured from the row's tallest chart -- it
// can never be left pinned to a chart's prior (larger) height after a
// resize. The droptarget after the chart is an in-flow flex item under
// GridRow's indefinite `height: fit-content`, where a percentage height
// resolves to `auto`/is ignored per the flexbox spec (defeating the
// `align-self: stretch` CSS already declares for it) -- it gets an
// explicit `auto` instead, letting that stretch actually apply.
expect(getDroptargetHeights()).toEqual(['100%', 'auto']);
});
test('trailing droptarget also gets a CSS-driven height when the row is full (regression for #37644)', () => {
const { container } = setup({ editMode: true, occupiedColumnCount: 12 });
const getDroptargetHeights = () =>
Array.from(
container.querySelectorAll<HTMLElement>('.empty-droptarget--vertical'),
).map(el => el.style.height);
// With no remaining columns, the droptarget after the last chart is
// treated as a side target too (isTrailingSideTarget), so it takes the
// same '100%' CSS height as the leading one instead of 'auto'.
expect(getDroptargetHeights()).toEqual(['100%', '100%']);
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('visibility handling for intersection observers', () => {
const mockIntersectionObserver = jest.fn();

View File

@@ -22,7 +22,6 @@ import {
useCallback,
useRef,
useEffect,
useLayoutEffect,
useMemo,
memo,
RefObject,
@@ -102,6 +101,7 @@ const GridRow = styled.div<{ editMode: boolean }>`
align-self: center;
&.empty-droptarget--vertical {
min-width: ${theme.sizeUnit * 4}px;
align-self: stretch;
&:not(:last-child) {
width: ${theme.sizeUnit * 4}px;
}
@@ -183,7 +183,6 @@ const Row = memo((props: RowProps) => {
const [isFocused, setIsFocused] = useState(false);
const [isInView, setIsInView] = useState(false);
const [hoverMenuHovered, setHoverMenuHovered] = useState(false);
const [containerHeight, setContainerHeight] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const isComponentVisibleRef = useRef(isComponentVisible);
@@ -284,14 +283,6 @@ const Row = memo((props: RowProps) => {
};
}, []);
useLayoutEffect(() => {
if (!editMode) return;
const updatedHeight = containerRef.current?.clientHeight;
if (updatedHeight !== undefined && updatedHeight !== containerHeight) {
setContainerHeight(updatedHeight);
}
});
const handleChangeFocus = useCallback((nextFocus: boolean) => {
setIsFocused(Boolean(nextFocus));
}, []);
@@ -403,7 +394,7 @@ const Row = memo((props: RowProps) => {
)}
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
height: '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
>
@@ -416,54 +407,68 @@ const Row = memo((props: RowProps) => {
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
)}
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id as string}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({
dropIndicatorProps,
}: {
dropIndicatorProps: JsonObject;
}) => dropIndicatorProps && <div {...dropIndicatorProps} />}
</Droppable>
)}
</Fragment>
))}
rowItems.map((componentId, itemIndex) => {
const isTrailingSideTarget =
remainColumnCount === 0 && itemIndex === rowItems.length - 1;
return (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id as string}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
isTrailingSideTarget && 'droptarget-side',
)}
editMode
style={{
// Only the last target in a full row (the absolutely
// positioned "side" target) actually resolves a
// percentage height -- for every other, in-flow
// target, GridRow's height is `fit-content`
// (indefinite), and a percentage height on a flex
// item under an indefinite-height parent resolves to
// `auto`/is ignored per the CSS flexbox spec, which
// defeats the `align-self: stretch` the CSS above
// already declares for it. `height: 'auto'` lets that
// stretch actually apply instead of collapsing the
// target to its content size.
height: isTrailingSideTarget ? '100%' : 'auto',
...(isTrailingSideTarget && { width: 16 }),
}}
>
{({
dropIndicatorProps,
}: {
dropIndicatorProps: JsonObject;
}) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
);
})}
</GridRow>
</WithPopoverMenu>
),
@@ -471,7 +476,6 @@ const Row = memo((props: RowProps) => {
backgroundStyle.className,
backgroundStyle.value,
columnWidth,
containerHeight,
depth,
editMode,
handleChangeBackground,

View File

@@ -265,6 +265,101 @@ test('Switching tabs', async () => {
expect(props.onChangeTab).toHaveBeenCalled();
});
test.each([false, true])(
'A childless TABS component does not register an active tab (editMode=%s)',
editMode => {
// An unresolved id reaches activeTabs as `undefined`, which
// `JSON.stringify` coerces to `null` in the permalink request body.
const props = createProps();
props.editMode = editMode;
props.component.children = [];
render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(props.setActiveTab).not.toHaveBeenCalled();
},
);
test.each([false, true])(
'The first child added to an empty TABS component is activated (editMode=%s)',
editMode => {
const props = createProps();
const [tabId] = props.component.children;
props.editMode = editMode;
props.component.children = [];
const { rerender } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(props.setActiveTab).not.toHaveBeenCalled();
rerender(
<Tabs {...props} component={{ ...props.component, children: [tabId] }} />,
);
expect(props.setActiveTab.mock.calls).toEqual([[tabId]]);
expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
},
);
test('A tab added after deleting the last tab is selected and registered', async () => {
const props = createProps();
const [deletedTabId, newTabId] = props.component.children;
props.component.children = [deletedTabId];
const { rerender } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(props.setActiveTab.mock.calls).toEqual([[deletedTabId]]);
expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
await userEvent.click(screen.getByRole('button', { name: 'remove' }));
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(props.deleteComponent).toHaveBeenCalledWith(
deletedTabId,
props.component.id,
);
rerender(
<Tabs {...props} component={{ ...props.component, children: [] }} />,
);
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
props.setActiveTab.mockClear();
await userEvent.click(screen.getByRole('button', { name: 'Add tab' }));
expect(props.createComponent).toHaveBeenCalled();
expect(props.setActiveTab).not.toHaveBeenCalled();
rerender(
<Tabs
{...props}
component={{ ...props.component, children: [newTabId] }}
/>,
);
expect(props.setActiveTab.mock.calls).toEqual([[newTabId, deletedTabId]]);
expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
});
test.each([false, true])(
'A populated TABS component registers its active tab (editMode=%s)',
editMode => {
const props = createProps();
props.editMode = editMode;
render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(props.setActiveTab.mock.calls).toEqual([['TAB-AsMaxdYL_t']]);
},
);
test('activeTabs hydrated from a permalink selects the matching tab content', () => {
// Regression guard for #36132: when a dashboard is opened via a
// permalink/anchor (including embedded dashboards), the permalink state is

View File

@@ -153,7 +153,7 @@ const Tabs = (props: TabsProps): ReactElement => {
};
}, [activeTabs, props.component, directPathToChild]);
const [activeKey, setActiveKey] = useState<string>(initActiveKey);
const [activeKey, setActiveKey] = useState<string | undefined>(initActiveKey);
const [selectedTabIndex, setSelectedTabIndex] =
useState<number>(initTabIndex);
const [dropPosition, setDropPosition] = useState<string | null>(null);
@@ -166,6 +166,26 @@ const Tabs = (props: TabsProps): ReactElement => {
const prevTabIds = usePrevious(props.component.children);
useEffect(() => {
// Resolve missing or deleted active keys when children become available
// so a tab added to an empty container is selected and registered.
const tabId = props.component.children[selectedTabIndex];
if (
tabId &&
(!activeKey || !props.component.children.includes(activeKey))
) {
setActiveKey(tabId);
}
}, [activeKey, props.component.children, selectedTabIndex]);
useEffect(() => {
// A TABS component with no children resolves no tab id, so there is
// nothing to activate. Dispatching the unresolved id would register an
// `undefined` entry in dashboardState.activeTabs, which JSON.stringify
// coerces to `null` when the dashboard state is posted to the permalink
// endpoint.
if (!activeKey) {
return;
}
if (prevActiveKey) {
props.setActiveTab(activeKey, prevActiveKey);
} else {

View File

@@ -184,7 +184,7 @@ export interface TabsRendererProps {
tabsDragSourceRef?: RefObject<HTMLDivElement>;
handleDeleteComponent: () => void;
tabsComponent: TabsComponent;
activeKey: string;
activeKey: string | undefined;
tabIds: string[];
handleClickTab: (index: number) => void;
handleEdit: AntdTabsProps['onEdit'];

View File

@@ -17,7 +17,9 @@
* under the License.
*/
import React from 'react';
import fetchMock from 'fetch-mock';
import {
act,
render,
screen,
userEvent,
@@ -34,6 +36,7 @@ import { useDownloadMenuItems } from '.';
const mockAddSuccessToast = jest.fn();
const mockAddDangerToast = jest.fn();
const mockAddInfoToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
@@ -41,6 +44,7 @@ jest.mock('src/components/MessageToasts/withToasts', () => ({
useToasts: () => ({
addSuccessToast: mockAddSuccessToast,
addDangerToast: mockAddDangerToast,
addInfoToast: mockAddInfoToast,
}),
}));
@@ -68,6 +72,11 @@ const createProps = () => ({
userCanExport: true,
});
// The default test store has an empty user; most tests exercise a logged-in
// session with an email on file.
const loggedInState = { user: { userId: 1, email: 'admin@example.com' } };
const guestState = { user: {} };
const MenuWrapper = () => {
const downloadMenuItem = useDownloadMenuItems(createProps());
const menuItems: MenuItem[] = [downloadMenuItem];
@@ -87,14 +96,46 @@ const MenuWrapperWithProps = (
return <Menu forceSubMenuRender items={menuItems} />;
};
// jsdom cannot follow an <a download> click, so record what was clicked.
let clickedDownloads: { href: string | null; download: string | null }[] = [];
let anchorClickSpy: jest.SpyInstance;
const lastDownloadHref = () =>
clickedDownloads.length
? clickedDownloads[clickedDownloads.length - 1].href
: null;
// forceSubMenuRender keeps the items mounted while the submenu is visually
// closed, so they sit under `pointer-events: none`; and user-event's own delay
// is a setTimeout that never fires once a test installs fake timers.
const clickMenuItem = (label: string) =>
userEvent.click(screen.getByText(label), {
pointerEventsCheck: 0,
delay: null,
});
const originalCreateObjectURL = window.URL.createObjectURL;
const originalRevokeObjectURL = window.URL.revokeObjectURL;
const originalLocation = window.location;
beforeEach(() => {
jest.clearAllMocks();
// The ready link is confirmed with HEAD before the browser is sent to it.
fetchMock.head('glob:*/api/v1/dashboard/export_xlsx/download/*', 200);
clickedDownloads = [];
anchorClickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function recordClick(this: HTMLAnchorElement) {
clickedDownloads.push({
href: this.getAttribute('href'),
download: this.getAttribute('download'),
});
});
// Reset the implementation each test: clearAllMocks resets call history but
// not mockReturnValue, so an override in one test would otherwise leak.
(isFeatureEnabled as jest.Mock).mockReturnValue(false);
// @ts-ignore
delete window.location;
window.location = { href: '' } as Location;
});
// "Export Images to Excel" is gated on the webdriver screenshot feature flags.
@@ -102,14 +143,20 @@ const enableWebDriverScreenshot = () =>
(isFeatureEnabled as jest.Mock).mockReturnValue(true);
afterEach(() => {
fetchMock.removeRoutes();
fetchMock.clearHistory();
anchorClickSpy.mockRestore();
window.URL.createObjectURL = originalCreateObjectURL;
window.URL.revokeObjectURL = originalRevokeObjectURL;
window.location = originalLocation;
jest.useRealTimers();
});
test('Should render all menu items', () => {
enableWebDriverScreenshot();
render(<MenuWrapper />, {
useRedux: true,
initialState: loggedInState,
});
// Screenshot options
@@ -126,42 +173,50 @@ test('Should render all menu items', () => {
test('Export Images to Excel is hidden when the webdriver is not enabled', () => {
// Default: webdriver screenshot flags off. Image export needs the webdriver,
// so only the data export is offered.
render(<MenuWrapper />, { useRedux: true });
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
});
test('Excel export items are hidden when userCanExport is false', () => {
render(<MenuWrapperWithProps userCanExport={false} />, { useRedux: true });
render(<MenuWrapperWithProps userCanExport={false} />, {
useRedux: true,
initialState: loggedInState,
});
expect(screen.queryByText('Export Data to Excel')).not.toBeInTheDocument();
expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
// YAML export is not gated and remains visible
// YAML export is not gated on userCanExport and remains visible
expect(screen.getByText('Export YAML')).toBeInTheDocument();
});
test('sessions without a user id get the data export but no bundle exports', () => {
// The API refuses bundle exports for them, so offering them only yields errors.
render(<MenuWrapper />, { useRedux: true, initialState: guestState });
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
expect(screen.queryByText('Export YAML')).not.toBeInTheDocument();
expect(screen.queryByText('Export as Example')).not.toBeInTheDocument();
});
test('Export Data to Excel posts mode "data" and shows a pending toast', async () => {
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
render(<MenuWrapper />, { useRedux: true });
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
// forceSubMenuRender keeps the submenu item in the DOM for querying
// even while visually closed (pointer-events: none); bypass user-event's
// pointer-events check rather than simulating the hover-to-open flow.
await userEvent.click(screen.getByText('Export Data to Excel'), {
pointerEventsCheck: 0,
});
await clickMenuItem('Export Data to Excel');
await waitFor(() => {
expect(mockSupersetClient.post).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/123/export_xlsx/',
jsonPayload: { active_data_mask: {}, mode: 'data' },
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
);
});
});
@@ -172,24 +227,174 @@ test('Export Images to Excel posts mode "images" and shows a pending toast', asy
json: { job_id: 'abc' },
} as never);
render(<MenuWrapper />, { useRedux: true });
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
// See forceSubMenuRender note above.
await userEvent.click(screen.getByText('Export Images to Excel'), {
pointerEventsCheck: 0,
});
await clickMenuItem('Export Images to Excel');
await waitFor(() => {
expect(mockSupersetClient.post).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/123/export_xlsx/',
jsonPayload: { active_data_mask: {}, mode: 'images' },
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
);
});
});
test('Export Data to Excel polls status and auto-downloads once ready', async () => {
// A guest/embedded session has no email to be notified at, so completion is
// discovered by polling export_xlsx/status/<job_id>/ instead -- exercised
// here regardless of session type, since the same polling drives the
// auto-download for a regular session too.
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValueOnce({
json: {
status: 'ready',
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
},
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
),
);
await act(async () => {
jest.advanceTimersByTime(3000);
});
// The download is handed to the browser via an anchor (no second
// SupersetClient.get, so the whole workbook is never buffered in page memory).
await waitFor(() => {
expect(mockSupersetClient.get).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/export_xlsx/status/abc/',
});
expect(lastDownloadHref()).toBe(
'/api/v1/dashboard/export_xlsx/download/abc/',
);
expect(mockAddSuccessToast).toHaveBeenCalledWith(
'Your export is ready and downloading.',
);
});
});
test('the ready download goes through an anchor and never navigates the page', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValueOnce({
json: {
status: 'ready',
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
},
} as never);
const hrefBefore = window.location.href;
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
await act(async () => {
jest.advanceTimersByTime(3000);
});
// A plain anchor: no frame (so no frame-src CSP exception), no `download`
// attribute (Chrome drops those without user activation), and the dashboard
// (or an embedding parent) is never navigated away.
await waitFor(() =>
expect(lastDownloadHref()).toBe(
'/api/v1/dashboard/export_xlsx/download/abc/',
),
);
expect(clickedDownloads[0].download).toBeNull();
expect(window.location.href).toBe(hrefBefore);
expect(document.body.querySelectorAll('iframe')).toHaveLength(0);
expect(
fetchMock.callHistory.calls('glob:*/export_xlsx/download/abc/'),
).toHaveLength(1);
});
test('Export Data to Excel keeps polling while status is pending', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValue({
json: { status: 'pending' },
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
),
);
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(2));
// Still pending -- no terminal toast, and the browser never navigated.
expect(mockAddDangerToast).not.toHaveBeenCalled();
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
'Your export is ready and downloading.',
);
});
test('Export Data to Excel shows an error toast when the export job fails', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValue({
json: { status: 'error', message: 'The export could not be built.' },
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
),
);
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'The export could not be built.',
);
});
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
'Your export is ready and downloading.',
);
});
test('Export Data to Excel shows an "already in progress" toast when throttled', async () => {
// The throttle response is 202 with a message but no job_id.
mockSupersetClient.post.mockResolvedValue({
@@ -198,15 +403,12 @@ test('Export Data to Excel shows an "already in progress" toast when throttled',
},
} as never);
render(<MenuWrapper />, { useRedux: true });
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
// See forceSubMenuRender note above.
await userEvent.click(screen.getByText('Export Data to Excel'), {
pointerEventsCheck: 0,
});
await clickMenuItem('Export Data to Excel');
await waitFor(() => {
expect(mockAddSuccessToast).toHaveBeenCalledWith(
expect(mockAddInfoToast).toHaveBeenCalledWith(
'An export for this dashboard is already in progress.',
);
});
@@ -216,12 +418,9 @@ test('Export Data to Excel shows a config error toast on 501', async () => {
mockSupersetClient.post.mockRejectedValue(new Error('not configured'));
mockGetClientErrorObject.mockResolvedValue({ status: 501 });
render(<MenuWrapper />, { useRedux: true });
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
// See forceSubMenuRender note above.
await userEvent.click(screen.getByText('Export Data to Excel'), {
pointerEventsCheck: 0,
});
await clickMenuItem('Export Data to Excel');
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
@@ -234,12 +433,9 @@ test('Export Data to Excel shows a generic error toast on other failures', async
mockSupersetClient.post.mockRejectedValue(new Error('boom'));
mockGetClientErrorObject.mockResolvedValue({ status: 500 });
render(<MenuWrapper />, { useRedux: true });
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
// See forceSubMenuRender note above.
await userEvent.click(screen.getByText('Export Data to Excel'), {
pointerEventsCheck: 0,
});
await clickMenuItem('Export Data to Excel');
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
@@ -264,12 +460,9 @@ test('Export as Example calls SupersetClient.get with correct endpoint', async (
window.URL.createObjectURL = createObjectURL;
window.URL.revokeObjectURL = revokeObjectURL;
render(<MenuWrapper />, { useRedux: true });
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
// See forceSubMenuRender note above.
await userEvent.click(screen.getByText('Export as Example'), {
pointerEventsCheck: 0,
});
await clickMenuItem('Export as Example');
await waitFor(() => {
expect(mockSupersetClient.get).toHaveBeenCalledWith({
@@ -286,12 +479,9 @@ test('Export as Example calls SupersetClient.get with correct endpoint', async (
test('Export as Example shows error toast on failure', async () => {
mockSupersetClient.get.mockRejectedValue(new Error('Network error'));
render(<MenuWrapper />, { useRedux: true });
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
// See forceSubMenuRender note above.
await userEvent.click(screen.getByText('Export as Example'), {
pointerEventsCheck: 0,
});
await clickMenuItem('Export as Example');
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
@@ -391,3 +581,341 @@ test('Enabled screenshot items should not show tooltip icon', () => {
mockIsFeatureEnabled.mockReset();
});
// ---------------------------------------------------------------------------
// Delivery follows the requester identity, not iframe presence: a guest or
// anonymous session (no userId) has no email channel, so the toast must not
// promise one, and the image export (webdriver-rendered, guests cannot open
// Explore) is hidden.
// ---------------------------------------------------------------------------
test('guest session: export toast promises auto-download, not an email', async () => {
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: guestState });
await clickMenuItem('Export Data to Excel');
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
'Your export is being generated. Please, do not leave the page.',
{ noDuplicate: true },
),
);
});
test('guest session: Export Images to Excel is hidden even with the webdriver enabled', () => {
enableWebDriverScreenshot();
render(<MenuWrapper />, { useRedux: true, initialState: guestState });
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
});
test('logged-in user without an email gets the delivery-neutral toast', async () => {
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
render(<MenuWrapper />, {
useRedux: true,
initialState: { user: { userId: 1 } },
});
await clickMenuItem('Export Data to Excel');
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
'Your export is being generated. Please, do not leave the page.',
{ noDuplicate: true },
),
);
});
test('a "running" status restarts the wait window, so queue delay is not counted', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
// Worker picks the job up on the first poll; still running just past the
// original 12 minute deadline; done on the poll after that.
mockSupersetClient.get
.mockResolvedValueOnce({ json: { status: 'running' } } as never)
.mockResolvedValueOnce({ json: { status: 'running' } } as never)
.mockResolvedValueOnce({
json: {
status: 'ready',
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
},
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
// t ~= 12m01s: past the enqueue-based deadline, within the restarted one
// (running was observed at t=3s). Without the restart this poll would give
// up with a danger toast instead of continuing.
await act(async () => {
jest.advanceTimersByTime(12 * 60 * 1000 - 2000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(2));
expect(mockAddDangerToast).not.toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(lastDownloadHref()).toBe(
'/api/v1/dashboard/export_xlsx/download/abc/',
);
expect(mockAddSuccessToast).toHaveBeenCalledWith(
'Your export is ready and downloading.',
);
});
});
test('a transient poll failure keeps polling and still downloads', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get
.mockRejectedValueOnce(new Error('network blip'))
.mockResolvedValueOnce({
json: {
status: 'ready',
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
},
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
expect(mockAddDangerToast).not.toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(lastDownloadHref()).toBe(
'/api/v1/dashboard/export_xlsx/download/abc/',
);
expect(mockAddSuccessToast).toHaveBeenCalledWith(
'Your export is ready and downloading.',
);
});
});
test('poll failures past the deadline give up with an error toast', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockRejectedValue(new Error('server down'));
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
// Jump past the 12 minute deadline; the next failing poll must give up.
await act(async () => {
jest.advanceTimersByTime(13 * 60 * 1000);
});
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Sorry, something went wrong. Try again later.',
);
});
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
'Your export is ready and downloading.',
);
});
test('a "ready" status with no download_url is an error, not a fake success', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValue({
json: { status: 'ready' },
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Sorry, something went wrong. Try again later.',
);
});
expect(mockAddSuccessToast).not.toHaveBeenCalled();
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
'Your export is ready and downloading.',
);
});
test('unmounting stops the polling loop', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValue({
json: { status: 'pending' },
} as never);
const { unmount } = render(<MenuWrapper />, {
useRedux: true,
initialState: loggedInState,
});
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
unmount();
await act(async () => {
jest.advanceTimersByTime(30000);
});
expect(mockSupersetClient.get).toHaveBeenCalledTimes(1);
});
test('the pending toast is announced once, not re-emitted on every poll', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValue({
json: { status: 'pending' },
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockAddInfoToast).toHaveBeenCalledTimes(1));
// Several poll cycles later, no additional pending toast has been emitted:
// re-emitting would spawn a fresh role="alert" every poll for a slow export.
for (let i = 0; i < 4; i += 1) {
await act(async () => {
jest.advanceTimersByTime(3000);
});
}
await waitFor(() =>
expect(mockSupersetClient.get.mock.calls.length).toBeGreaterThan(3),
);
expect(mockAddInfoToast).toHaveBeenCalledTimes(1);
});
const READY_URL = '/api/v1/dashboard/export_xlsx/download/abc/';
test('a link that fails at download time is reported, not navigated to', async () => {
// The narrow race where the object vanishes between the status check and
// the click: without the HEAD probe the page would navigate to a 410.
jest.useFakeTimers();
fetchMock.removeRoutes();
fetchMock.head('glob:*/api/v1/dashboard/export_xlsx/download/*', 410);
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValueOnce({
json: { status: 'ready', download_url: READY_URL },
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() =>
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Sorry, something went wrong. Try again later.',
),
);
expect(clickedDownloads).toHaveLength(0);
expect(mockAddSuccessToast).not.toHaveBeenCalled();
});
test('the download anchor is detached again once clicked', async () => {
// Nothing is left in document.body to accumulate across repeated exports.
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValueOnce({
json: { status: 'ready', download_url: READY_URL },
} as never);
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(lastDownloadHref()).toBe(READY_URL));
expect(document.body.querySelector(`a[href="${READY_URL}"]`)).toBeNull();
});
test('unmounting while the export POST is in flight suppresses its follow-up', async () => {
// Cleanup has already run by the time the POST settles, so a toast would
// land on another page and the poll timer would be untracked.
jest.useFakeTimers();
let settlePost: (value: unknown) => void = () => {};
mockSupersetClient.post.mockReturnValue(
new Promise(resolve => {
settlePost = resolve;
}) as never,
);
const { unmount } = render(<MenuWrapper />, {
useRedux: true,
initialState: loggedInState,
});
await clickMenuItem('Export Data to Excel');
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
unmount();
await act(async () => {
settlePost({ json: { job_id: 'abc' } });
});
expect(mockAddInfoToast).not.toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(30000);
});
expect(mockSupersetClient.get).not.toHaveBeenCalled();
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SyntheticEvent } from 'react';
import { SyntheticEvent, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { logging } from '@apache-superset/core/utils';
import { t } from '@apache-superset/core/translation';
@@ -43,6 +43,22 @@ import { useToasts } from 'src/components/MessageToasts/withToasts';
import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip';
import { DownloadScreenshotFormat } from './types';
// Polling always outlives the server's hard task budget (11 minutes): the
// export email is best-effort, so the client cannot rely on it as a fallback.
const EXPORT_STATUS_POLL_INTERVAL_MS = 3000;
const EXPORT_STATUS_POLL_TIMEOUT_MS = 12 * 60 * 1000;
interface ExportStatusResponse {
status?: 'pending' | 'running' | 'ready' | 'error';
download_url?: string;
message?: string;
}
interface ExportPollState {
deadline: number;
sawRunning: boolean;
}
export interface UseDownloadMenuItemsProps {
pdfMenuItemTitle: string;
imageMenuItemTitle: string;
@@ -70,8 +86,35 @@ export const useDownloadMenuItems = (
canExportImage,
} = props;
const { addDangerToast, addSuccessToast } = useToasts();
const { addDangerToast, addSuccessToast, addInfoToast } = useToasts();
// Track the in-flight poll timer so navigating away stops the polling
// (and the full-page navigation it would eventually trigger).
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>();
const unmountedRef = useRef(false);
useEffect(
() => () => {
unmountedRef.current = true;
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
}
},
[],
);
const dataMask = useSelector((state: RootState) => state.dataMask);
const user = useSelector((state: RootState) => state.user);
// Guests and anonymous sessions have no userId and cannot be emailed.
const isGuestSession = !user?.userId;
const canReceiveEmail = Boolean(user?.userId && user?.email);
const addExportPendingToast = () =>
addInfoToast(
canReceiveEmail
? t(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
)
: t('Your export is being generated. Please, do not leave the page.'),
{ noDuplicate: true },
);
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
const buildActiveDataMask = (): Record<string, { extraFormData: object }> =>
@@ -167,24 +210,142 @@ export const useDownloadMenuItems = (
}
};
const triggerExportDownload = async (downloadUrl: string) => {
// A plain anchor to the attachment streams it to disk without leaving the
// page or needing a frame (so no frame-src CSP exception). No `download`
// attribute: by now polling has consumed any user activation, and Chrome
// drops such clicks silently. An error response would navigate instead,
// so confirm the exact link first and report a failure as a toast.
try {
const probe = await fetch(downloadUrl, {
method: 'HEAD',
credentials: 'same-origin',
});
if (!probe.ok) {
throw new Error(`download link answered ${probe.status}`);
}
} catch (error) {
logging.error(error);
if (!unmountedRef.current) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
return false;
}
if (unmountedRef.current) {
return false;
}
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
return true;
};
const pollExportStatus = (jobId: string, pollState: ExportPollState) => {
if (unmountedRef.current) {
return;
}
SupersetClient.get({
endpoint: `/api/v1/dashboard/export_xlsx/status/${jobId}/`,
})
.then(({ json }) => {
// A response in flight when the component unmounts must not navigate
// (redirect) or toast on whatever page the user moved to.
if (unmountedRef.current) {
return;
}
const {
status,
download_url: downloadUrl,
message,
} = json as ExportStatusResponse;
if (status === 'ready') {
if (downloadUrl) {
triggerExportDownload(downloadUrl).then(started => {
if (started) {
addSuccessToast(t('Your export is ready and downloading.'));
}
});
} else {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
return;
}
if (status === 'error') {
addDangerToast(
message || t('Sorry, something went wrong. Try again later.'),
);
return;
}
if (status === 'running' && !pollState.sawRunning) {
// The task's execution budget only starts when a worker picks it
// up; restart the wait window then, so queue delay doesn't eat it.
pollState.sawRunning = true;
pollState.deadline = Date.now() + EXPORT_STATUS_POLL_TIMEOUT_MS;
}
if (Date.now() > pollState.deadline) {
addDangerToast(
t('Your export is taking longer than expected. Try again later.'),
);
return;
}
// The pending toast is announced once at enqueue, not re-emitted on
// every poll: an info toast expires after a few seconds and noDuplicate
// only dedupes live toasts, so re-emitting would create a fresh
// role="alert" every poll for the whole export -- noisy, and hostile to
// screen readers.
pollTimerRef.current = setTimeout(
() => pollExportStatus(jobId, pollState),
EXPORT_STATUS_POLL_INTERVAL_MS,
);
})
.catch(error => {
if (unmountedRef.current) {
return;
}
// A transient polling failure shouldn't give up the wait -- the export
// itself may still succeed -- so keep polling until the timeout.
logging.error(error);
if (Date.now() > pollState.deadline) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
return;
}
pollTimerRef.current = setTimeout(
() => pollExportStatus(jobId, pollState),
EXPORT_STATUS_POLL_INTERVAL_MS,
);
});
};
const onExportXlsx = async (mode: 'data' | 'images') => {
try {
const { json } = await SupersetClient.post({
endpoint: `/api/v1/dashboard/${dashboardId}/export_xlsx/`,
jsonPayload: { active_data_mask: buildActiveDataMask(), mode },
});
// Settled after unmount: the timer would be untracked, the toast stray.
if (unmountedRef.current) {
return;
}
// The throttle response (an export is already running) returns 202 with a
// message but no job_id; only a freshly enqueued job carries a job_id.
if ((json as { job_id?: string })?.job_id) {
addSuccessToast(
t(
"Your export is being prepared. You'll receive an email when it's ready.",
),
const jobId = (json as { job_id?: string })?.job_id;
if (jobId) {
addExportPendingToast();
pollTimerRef.current = setTimeout(
() =>
pollExportStatus(jobId, {
deadline: Date.now() + EXPORT_STATUS_POLL_TIMEOUT_MS,
sawRunning: false,
}),
EXPORT_STATUS_POLL_INTERVAL_MS,
);
} else {
addSuccessToast(
t('An export for this dashboard is already in progress.'),
);
// The throttle 202 carries no job_id, so there is nothing to poll and
// nothing is downloading here: an info toast, not a success one.
addInfoToast(t('An export for this dashboard is already in progress.'));
}
} catch (error) {
// status comes from the response (Partial<SupersetClientResponse>), which
@@ -192,6 +353,9 @@ export const useDownloadMenuItems = (
const { status } = (await getClientErrorObject(error)) as {
status?: number;
};
if (unmountedRef.current) {
return;
}
if (status === 501) {
addDangerToast(t('Excel export is not configured on this server.'));
} else {
@@ -252,11 +416,10 @@ export const useDownloadMenuItems = (
label: t('Export Data to Excel'),
onClick: () => onExportXlsx('data'),
},
// Image export renders charts through the headless webdriver, so only
// offer it where that infrastructure is available (same signal as the
// PDF/PNG image downloads above); otherwise non-table charts would
// silently come back empty.
...(isWebDriverScreenshotEnabled
// Needs the webdriver infrastructure and a real session: the
// webdriver cannot render under a guest identity (the API rejects
// guest image exports too).
...(isWebDriverScreenshotEnabled && !isGuestSession
? [
{
key: 'export-xlsx-images',
@@ -267,12 +430,17 @@ export const useDownloadMenuItems = (
: []),
]
: []),
{
key: 'export-yaml',
label: t('Export YAML'),
onClick: onExportZip,
},
...(userCanExport
// Bundle exports are refused for sessions without a user id.
...(!isGuestSession
? [
{
key: 'export-yaml',
label: t('Export YAML'),
onClick: onExportZip,
},
]
: []),
...(userCanExport && !isGuestSession
? [
{
key: 'export-as-example',

View File

@@ -21,6 +21,8 @@ import { RefObject, ReactNode, useCallback, memo } from 'react';
import { styled } from '@apache-superset/core/theme';
import cx from 'classnames';
import { HOVER_MENU_Z_INDEX } from 'src/dashboard/constants';
interface HoverMenuProps {
position?: 'left' | 'top';
innerRef?: RefObject<HTMLDivElement> | null;
@@ -32,7 +34,7 @@ const HoverStyleOverrides = styled.div`
.hover-menu {
opacity: 0;
position: absolute;
z-index: 11; /* one more than DragDroppable */
z-index: ${HOVER_MENU_Z_INDEX};
font-size: ${({ theme }) => theme.fontSize};
}

View File

@@ -36,6 +36,7 @@ import {
BottomRightResizeHandle,
} from './ResizableHandle';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import { RESIZE_HANDLE_Z_INDEX } from 'src/dashboard/constants';
import resizableConfig from '../../util/resizableConfig';
import {
GRID_BASE_UNIT,
@@ -130,7 +131,7 @@ const StyledResizable = styled(Resizable)`
.resize-handle {
opacity: 0;
z-index: 10;
z-index: ${RESIZE_HANDLE_Z_INDEX};
&--bottom-right {
position: absolute;

View File

@@ -43,6 +43,12 @@ export const FILTER_BAR_TABS_HEIGHT = 46;
export const BUILDER_SIDEPANEL_WIDTH = 374;
export const OVERWRITE_INSPECT_FIELDS = ['css', 'json_metadata.filter_scopes'];
export const EMPTY_CONTAINER_Z_INDEX = 10;
// A grid component's hover menu floats outside the component's own box, so it
// must paint above the drop targets it overlaps.
export const HOVER_MENU_Z_INDEX = EMPTY_CONTAINER_Z_INDEX + 1;
// A hover menu rendered above a column overlaps the bottom edge of whatever
// sits above it, so resize handles have to win hit testing against it.
export const RESIZE_HANDLE_Z_INDEX = HOVER_MENU_Z_INDEX + 1;
export const DEFAULT_CROSS_FILTER_SCOPING: NativeFilterScope = {
rootPath: [DASHBOARD_ROOT_ID],

View File

@@ -22,6 +22,7 @@ import {
AutoRefreshProvider,
useAutoRefreshContext,
useIsAutoRefreshing,
useIsChartAutoRefreshing,
useIsRefreshInFlight,
} from './AutoRefreshContext';
@@ -135,3 +136,71 @@ test('useIsRefreshInFlight hook returns correct value inside provider', () => {
expect(result.current.isRefreshInFlight).toBe(true);
});
test('autoRefreshingChartIds starts empty', () => {
const { result } = renderHook(() => useAutoRefreshContext(), { wrapper });
expect(result.current.autoRefreshingChartIds).toEqual([]);
});
test('startAutoRefresh(chartIds) records only the affected chart ids', () => {
const { result } = renderHook(() => useAutoRefreshContext(), { wrapper });
act(() => {
result.current.startAutoRefresh([1, 2, 3]);
});
expect(result.current.isAutoRefreshing).toBe(true);
expect(result.current.autoRefreshingChartIds).toEqual([1, 2, 3]);
});
test('startAutoRefresh() with no chart ids records an empty batch', () => {
const { result } = renderHook(() => useAutoRefreshContext(), { wrapper });
act(() => {
result.current.startAutoRefresh();
});
expect(result.current.isAutoRefreshing).toBe(true);
expect(result.current.autoRefreshingChartIds).toEqual([]);
});
test('endAutoRefresh clears autoRefreshingChartIds', () => {
const { result } = renderHook(() => useAutoRefreshContext(), { wrapper });
act(() => {
result.current.startAutoRefresh([1, 2, 3]);
});
expect(result.current.autoRefreshingChartIds).toEqual([1, 2, 3]);
act(() => {
result.current.endAutoRefresh();
});
expect(result.current.autoRefreshingChartIds).toEqual([]);
});
test('useIsChartAutoRefreshing only reports true for charts in the in-flight batch', () => {
const { result } = renderHook(
() => ({
context: useAutoRefreshContext(),
isChart1AutoRefreshing: useIsChartAutoRefreshing(1),
isChart2AutoRefreshing: useIsChartAutoRefreshing(2),
}),
{ wrapper },
);
expect(result.current.isChart1AutoRefreshing).toBe(false);
expect(result.current.isChart2AutoRefreshing).toBe(false);
act(() => {
result.current.context.startAutoRefresh([1]);
});
expect(result.current.isChart1AutoRefreshing).toBe(true);
expect(result.current.isChart2AutoRefreshing).toBe(false);
act(() => {
result.current.context.endAutoRefresh();
});
expect(result.current.isChart1AutoRefreshing).toBe(false);
});

View File

@@ -29,15 +29,17 @@ import {
export interface AutoRefreshContextValue {
isAutoRefreshing: boolean;
isRefreshInFlight: boolean;
autoRefreshingChartIds: number[];
setIsAutoRefreshing: (value: boolean) => void;
setRefreshInFlight: (value: boolean) => void;
startAutoRefresh: () => void;
startAutoRefresh: (chartIds?: number[]) => void;
endAutoRefresh: () => void;
}
const AutoRefreshContext = createContext<AutoRefreshContextValue>({
isAutoRefreshing: false,
isRefreshInFlight: false,
autoRefreshingChartIds: [],
setIsAutoRefreshing: () => {},
setRefreshInFlight: () => {},
startAutoRefresh: () => {},
@@ -57,25 +59,37 @@ export const AutoRefreshProvider: FC<AutoRefreshProviderProps> = ({
}) => {
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
const [isRefreshInFlight, setRefreshInFlight] = useState(false);
const [autoRefreshingChartIds, setAutoRefreshingChartIds] = useState<
number[]
>([]);
const startAutoRefresh = useCallback(() => {
const startAutoRefresh = useCallback((chartIds: number[] = []) => {
setAutoRefreshingChartIds(chartIds);
setIsAutoRefreshing(true);
}, []);
const endAutoRefresh = useCallback(() => {
setIsAutoRefreshing(false);
setAutoRefreshingChartIds([]);
}, []);
const value = useMemo(
() => ({
isAutoRefreshing,
isRefreshInFlight,
autoRefreshingChartIds,
setIsAutoRefreshing,
setRefreshInFlight,
startAutoRefresh,
endAutoRefresh,
}),
[isAutoRefreshing, isRefreshInFlight, startAutoRefresh, endAutoRefresh],
[
isAutoRefreshing,
isRefreshInFlight,
autoRefreshingChartIds,
startAutoRefresh,
endAutoRefresh,
],
);
return (
@@ -98,4 +112,15 @@ export const useIsRefreshInFlight = (): boolean => {
return isRefreshInFlight;
};
/**
* Whether the given chart is part of the currently in-flight silent
* auto-refresh batch. Unlike useIsAutoRefreshing, this is scoped to a single
* chart so charts loading for unrelated reasons (e.g. mounting into a
* freshly-selected tab) still show their own spinner.
*/
export const useIsChartAutoRefreshing = (chartId: number): boolean => {
const { autoRefreshingChartIds } = useContext(AutoRefreshContext);
return autoRefreshingChartIds.includes(chartId);
};
export default AutoRefreshContext;

View File

@@ -24,6 +24,7 @@ import {
} from '@superset-ui/core';
import {
DASHBOARD_INFO_UPDATED,
DASHBOARD_SAVE_SUCCEEDED,
SET_FILTER_BAR_ORIENTATION,
SET_CROSS_FILTERS_ENABLED,
DASHBOARD_INFO_FILTERS_CHANGED,
@@ -71,6 +72,7 @@ type DashboardInfoReducerAction =
type DashboardInfoState = Partial<DashboardInfo> & {
last_modified_time?: number;
versionHistoryRevision?: number;
[key: string]: unknown;
};
@@ -124,6 +126,13 @@ export default function dashboardInfoReducer(
action: DashboardInfoReducerAction,
): DashboardInfoState {
switch (action.type) {
case DASHBOARD_SAVE_SUCCEEDED:
return (action as DashboardInfoAction).dashboardId === state.id
? {
...state,
versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
}
: state;
case DASHBOARD_INFO_UPDATED: {
const dashAction = action as DashboardInfoAction;
const newInfo = dashAction.newInfo || {};
@@ -189,6 +198,7 @@ export default function dashboardInfoReducer(
native_filter_configuration: newConfigWithScopes,
} as DashboardInfo['metadata'],
last_modified_time: Math.round(new Date().getTime() / 1000),
versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
};
}
case HYDRATE_DASHBOARD: {

View File

@@ -33,6 +33,7 @@ import type { DashboardLayout } from 'src/dashboard/types';
import {
CHART_TYPE,
COLUMN_TYPE,
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
ROW_TYPE,
@@ -231,6 +232,67 @@ describe('dashboardLayout reducer', () => {
});
});
test('should not move a component into its own descendant', () => {
const layout = {
parent: {
id: 'parent',
type: ROW_TYPE,
children: ['column'],
},
column: {
id: 'column',
type: COLUMN_TYPE,
children: ['nestedRow'],
parents: ['parent'],
},
nestedRow: {
id: 'nestedRow',
type: ROW_TYPE,
children: [],
parents: ['parent', 'column'],
},
};
const dropResult = {
source: { id: 'parent', type: ROW_TYPE, index: 0 },
destination: { id: 'nestedRow', type: ROW_TYPE, index: 0 },
dragging: { id: 'column', type: COLUMN_TYPE },
};
expect(
testReducer(layout, {
type: MOVE_COMPONENT,
payload: { dropResult },
}),
).toBe(layout);
});
test('should not move a component into a descendant with stale parents', () => {
const layout = {
parent: { id: 'parent', type: ROW_TYPE, children: ['column'] },
column: { id: 'column', type: COLUMN_TYPE, children: ['nestedRow'] },
nestedRow: {
id: 'nestedRow',
type: ROW_TYPE,
children: [],
parents: ['somewhere-else'],
},
};
const dropResult = {
source: { id: 'parent', type: ROW_TYPE, index: 0 },
destination: { id: 'nestedRow', type: ROW_TYPE, index: 0 },
dragging: { id: 'column', type: COLUMN_TYPE },
};
expect(
testReducer(layout, {
type: MOVE_COMPONENT,
payload: { dropResult },
}),
).toBe(layout);
});
test('should wrap a moved component in a row if need be', () => {
const layout = {
source: {

View File

@@ -47,6 +47,25 @@ import { HYDRATE_DASHBOARD } from '../actions/hydrate';
import { DashboardLayout } from '../types';
import { DropResult } from '../components/dnd/dragDroppableConfig';
// Walks `children` rather than trusting `parents`, which can be stale.
function isSelfOrDescendant(
layout: DashboardLayout,
ancestorId: string,
targetId: string,
): boolean {
const seen = new Set<string>();
const stack = [ancestorId];
while (stack.length) {
const id = stack.pop() as string;
if (id === targetId) return true;
if (!seen.has(id)) {
seen.add(id);
stack.push(...(layout[id]?.children ?? []));
}
}
return false;
}
interface DashboardLayoutAction {
type: string;
payload?: {
@@ -177,6 +196,13 @@ const actionHandlers: Record<
if (!source || !destination || !dragging) return state;
// Defense in depth against the drop guard in getDropPosition: moving a
// component into itself or into one of its own descendants detaches the
// subtree and leaves a cycle that no root walk can reach or repair.
if (isSelfOrDescendant(state, dragging.id, destination.id)) {
return state;
}
const nextEntities = reorderItem({
entitiesMap: state,
source,

View File

@@ -274,6 +274,29 @@ describe('DashboardState reducer', () => {
expect.arrayContaining(['TAB-Outer1', 'TAB-Inner1']),
);
});
// The component tests assert on a `setActiveTab` mock, so they stop short
// of the state that actually gets serialized into the permalink body.
test('stores a resolved tab id so activeTabs serializes without null', () => {
const store = mockStore({
dashboardState: { activeTabs: [] },
dashboardLayout: { present: { 'TAB-1': { parents: [] } } },
});
const thunkAction = setActiveTab('TAB-1')(
store.dispatch,
store.getState as () => RootState,
);
const result = typedDashboardStateReducer(
createMockDashboardState({ activeTabs: [] }),
thunkAction,
);
expect(result.activeTabs).toEqual(['TAB-1']);
expect(JSON.stringify({ activeTabs: result.activeTabs })).toBe(
'{"activeTabs":["TAB-1"]}',
);
});
});
// Pins a side effect of seeding activeTabs at hydration (see
// actions/hydrate.ts / util/getDefaultActiveTabs.ts): a non-empty seeded

View File

@@ -90,6 +90,7 @@ interface DashboardStateShape {
hasUnsavedChanges?: boolean;
dashboardIsSaving?: boolean;
lastModifiedTime?: number;
versionHistoryRevision?: number;
refreshFrequency?: number;
shouldPersistRefreshFrequency?: boolean;
isRefreshing?: boolean;
@@ -294,6 +295,7 @@ export default function dashboardStateReducer(
updatedColorScheme: false,
// server-side returns last_modified_time for latest change
lastModifiedTime: action.lastModifiedTime,
versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
};
},
[SET_UNSAVED_CHANGES](): DashboardStateShape {

View File

@@ -26,6 +26,7 @@ import getDropPositionOriginal, {
import {
CHART_TYPE,
COLUMN_TYPE,
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
HEADER_TYPE,
@@ -457,3 +458,85 @@ describe('getDropPosition', () => {
});
});
});
// The self-nesting guard needs ids and a parents chain, which getMocks above
// intentionally omits, so these build their own minimal mocks.
const selfNestingMocks = ({
componentId,
componentParents = [],
parentComponentId,
parentComponentParents = [],
}: {
componentId: string;
componentParents?: string[];
parentComponentId?: string;
parentComponentParents?: string[];
}) => [
{
getItem: () => ({ id: 'COLUMN-dragged', type: COLUMN_TYPE }),
getClientOffset: () => ({ x: 0, y: 0 }),
},
{
props: {
depth: 2,
component: {
id: componentId,
type: ROW_TYPE,
children: [],
parents: componentParents,
},
parentComponent: parentComponentId
? {
id: parentComponentId,
type: TAB_TYPE,
children: [],
parents: parentComponentParents,
}
: undefined,
orientation: 'column',
isDraggingOverShallow: true,
},
ref: {
getBoundingClientRect: () => ({
top: 0,
right: 100,
bottom: 100,
left: 0,
}),
},
},
];
test('returns DROP_FORBIDDEN when dropping a component into its own descendant', () => {
expect(
getDropPosition(
...selfNestingMocks({
componentId: 'ROW-nested-in-dragged',
componentParents: ['ROOT_ID', 'GRID_ID', 'COLUMN-dragged'],
}),
),
).toBe(DROP_FORBIDDEN);
});
test('returns DROP_FORBIDDEN when dropping a component as a sibling inside itself', () => {
expect(
getDropPosition(
...selfNestingMocks({
componentId: 'ROW-nested-in-dragged-sibling',
componentParents: ['ROOT_ID', 'GRID_ID', 'COLUMN-dragged'],
parentComponentId: 'COLUMN-dragged',
}),
),
).toBe(DROP_FORBIDDEN);
});
test('allows a drop on an unrelated component', () => {
expect(
getDropPosition(
...selfNestingMocks({
componentId: 'ROW-unrelated',
componentParents: ['ROOT_ID', 'GRID_ID'],
}),
),
).not.toBe(DROP_FORBIDDEN);
});

View File

@@ -88,6 +88,19 @@ export default function getDropPosition(
return null;
}
// A container cannot receive itself. Dropping it into one of its own
// descendants (as a child, or as a sibling of one) makes reorderItem detach
// the subtree from its real parent and close a cycle between the two, which
// is unreachable from the root and so never repaired downstream.
const isSelfOrDescendant = (target?: LayoutItem) =>
!!target &&
(target.id === draggingItem.id ||
(target.parents || []).includes(draggingItem.id));
if (isSelfOrDescendant(component) || isSelfOrDescendant(parentComponent)) {
return DROP_FORBIDDEN;
}
const validChild = isValidChild({
parentType: component.type,
parentDepth: componentDepth,

View File

@@ -0,0 +1,283 @@
/**
* 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 removeUnreachableComponents from 'src/dashboard/util/removeUnreachableComponents';
import type { DashboardComponent } from 'src/dashboard/types';
type Layout = Record<string, DashboardComponent>;
const component = (
id: string,
type: string,
children: string[] = [],
extra: Partial<DashboardComponent> = {},
): DashboardComponent => ({ id, type, children, meta: {}, ...extra });
// DASHBOARD_VERSION_KEY is a plain string in real position data
const withVersionKey = (layout: Layout): Layout =>
({ DASHBOARD_VERSION_KEY: 'v2', ...layout }) as unknown as Layout;
const reachableLayout = (): Layout =>
withVersionKey({
ROOT_ID: component('ROOT_ID', 'ROOT', ['GRID_ID']),
GRID_ID: component('GRID_ID', 'GRID', ['ROW-a']),
'ROW-a': component('ROW-a', 'ROW', ['CHART-a']),
'CHART-a': component('CHART-a', 'CHART', [], { meta: { chartId: 1 } }),
HEADER_ID: component('HEADER_ID', 'HEADER'),
});
// the shape of a real corrupted dashboard: a column moved into a row nested
// inside itself, leaving the pair pointing at each other and detached
const trappedComponents = (chartMeta: DashboardComponent['meta']): Layout => ({
'COLUMN-orphan': component(
'COLUMN-orphan',
'COLUMN',
['CHART-trapped', 'ROW-orphan'],
{ parents: ['ROOT_ID', 'GRID_ID', 'ROW-a'] },
),
'ROW-orphan': component('ROW-orphan', 'ROW', ['COLUMN-orphan'], {
parents: ['ROOT_ID', 'GRID_ID', 'ROW-a', 'COLUMN-orphan'],
}),
'CHART-trapped': component('CHART-trapped', 'CHART', [], {
parents: ['ROOT_ID', 'GRID_ID', 'ROW-a', 'COLUMN-orphan'],
meta: chartMeta,
}),
});
test('returns the same layout when everything is reachable', () => {
const layout = reachableLayout();
expect(removeUnreachableComponents(layout)).toBe(layout);
});
test('drops a detached subtree, including one that contains a cycle', () => {
const layout = { ...reachableLayout(), ...trappedComponents({}) };
expect(Object.keys(removeUnreachableComponents(layout)).sort()).toEqual(
Object.keys(reachableLayout()).sort(),
);
});
test('reattaches a detached chart to the grid, keeping its id and meta', () => {
const layout = {
...reachableLayout(),
...trappedComponents({ chartId: 2, width: 4 }),
};
const repaired = removeUnreachableComponents(layout);
expect(repaired['COLUMN-orphan']).toBeUndefined();
expect(repaired['ROW-orphan']).toBeUndefined();
const [, newRowId] = repaired.GRID_ID.children;
expect(repaired[newRowId]).toMatchObject({
type: 'ROW',
children: ['CHART-trapped'],
parents: ['ROOT_ID', 'GRID_ID'],
});
expect(repaired['CHART-trapped']).toMatchObject({
parents: ['ROOT_ID', 'GRID_ID', newRowId],
meta: { chartId: 2, width: 4 },
});
expect(layout.GRID_ID.children).toEqual(['ROW-a']);
});
test('reattaches detached markdown to a row and a header to the grid', () => {
const layout = {
...reachableLayout(),
'COLUMN-orphan': component('COLUMN-orphan', 'COLUMN', [
'HEADER-trapped',
'MARKDOWN-trapped',
'ROW-orphan',
]),
'ROW-orphan': component('ROW-orphan', 'ROW', ['COLUMN-orphan']),
'HEADER-trapped': component('HEADER-trapped', 'HEADER', [], {
meta: { text: 'Section title' },
}),
'MARKDOWN-trapped': component('MARKDOWN-trapped', 'MARKDOWN', [], {
meta: { code: '# Notes', width: 4 },
}),
};
const repaired = removeUnreachableComponents(layout);
expect(repaired['COLUMN-orphan']).toBeUndefined();
expect(repaired['ROW-orphan']).toBeUndefined();
const newRowId = repaired.GRID_ID.children[2];
// a header is not a valid row child, so it sits directly in the grid
expect(repaired.GRID_ID.children).toEqual([
'ROW-a',
'HEADER-trapped',
newRowId,
]);
expect(repaired['HEADER-trapped']).toMatchObject({
parents: ['ROOT_ID', 'GRID_ID'],
meta: { text: 'Section title' },
});
expect(repaired[newRowId]).toMatchObject({
type: 'ROW',
children: ['MARKDOWN-trapped'],
});
expect(repaired['MARKDOWN-trapped']).toMatchObject({
parents: ['ROOT_ID', 'GRID_ID', newRowId],
meta: { code: '# Notes', width: 4 },
});
});
test('reattaches a detached chart to the first tab of top-level tabs', () => {
const layout = withVersionKey({
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
GRID_ID: component('GRID_ID', 'GRID'),
'TABS-t': component('TABS-t', 'TABS', ['TAB-1', 'TAB-2']),
'TAB-1': component('TAB-1', 'TAB'),
'TAB-2': component('TAB-2', 'TAB'),
'CHART-trapped': component('CHART-trapped', 'CHART', [], {
meta: { chartId: 2 },
}),
});
const repaired = removeUnreachableComponents(layout);
const [newRowId] = repaired['TAB-1'].children;
expect(repaired['CHART-trapped'].parents).toEqual([
'ROOT_ID',
'TABS-t',
'TAB-1',
newRowId,
]);
});
test('reattaches a detached chart to the tab recorded in its stale parents', () => {
const layout = withVersionKey({
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
GRID_ID: component('GRID_ID', 'GRID'),
'TABS-t': component('TABS-t', 'TABS', ['TAB-1', 'TAB-2']),
'TAB-1': component('TAB-1', 'TAB'),
'TAB-2': component('TAB-2', 'TAB'),
'CHART-trapped': component('CHART-trapped', 'CHART', [], {
parents: ['ROOT_ID', 'TABS-t', 'TAB-2', 'ROW-gone', 'COLUMN-gone'],
meta: { chartId: 2 },
}),
});
const repaired = removeUnreachableComponents(layout);
expect(repaired['TAB-1'].children).toEqual([]);
const [newRowId] = repaired['TAB-2'].children;
expect(repaired['CHART-trapped'].parents).toEqual([
'ROOT_ID',
'TABS-t',
'TAB-2',
newRowId,
]);
});
test('returns detached headers and markdown to their tab in order', () => {
const staleParents = ['ROOT_ID', 'TABS-t', 'TAB-2', 'ROW-gone'];
const layout = withVersionKey({
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
GRID_ID: component('GRID_ID', 'GRID'),
'TABS-t': component('TABS-t', 'TABS', ['TAB-1', 'TAB-2']),
'TAB-1': component('TAB-1', 'TAB'),
'TAB-2': component('TAB-2', 'TAB'),
'CHART-trapped': component('CHART-trapped', 'CHART', [], {
parents: staleParents,
meta: { chartId: 2 },
}),
'HEADER-trapped': component('HEADER-trapped', 'HEADER', [], {
parents: ['ROOT_ID', 'TABS-t', 'TAB-2'],
}),
'MARKDOWN-trapped': component('MARKDOWN-trapped', 'MARKDOWN', [], {
parents: staleParents,
}),
});
const repaired = removeUnreachableComponents(layout);
expect(repaired['TAB-1'].children).toEqual([]);
const [chartRow, headerId, markdownRow] = repaired['TAB-2'].children;
expect(headerId).toBe('HEADER-trapped');
expect(repaired['HEADER-trapped'].parents).toEqual([
'ROOT_ID',
'TABS-t',
'TAB-2',
]);
expect(repaired[chartRow].children).toEqual(['CHART-trapped']);
expect(repaired[markdownRow].children).toEqual(['MARKDOWN-trapped']);
});
test('does not duplicate a detached chart that is also placed reachably', () => {
const layout = { ...reachableLayout(), ...trappedComponents({ chartId: 1 }) };
const repaired = removeUnreachableComponents(layout);
expect(repaired['CHART-trapped']).toBeUndefined();
expect(repaired.GRID_ID.children).toEqual(['ROW-a']);
});
test('keeps the detached empty grid of a dashboard with top-level tabs', () => {
const layout = withVersionKey({
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
GRID_ID: component('GRID_ID', 'GRID'),
'TABS-t': component('TABS-t', 'TABS', ['TAB-1']),
'TAB-1': component('TAB-1', 'TAB'),
HEADER_ID: component('HEADER_ID', 'HEADER'),
});
expect(removeUnreachableComponents(layout)).toBe(layout);
});
test('clears the children of a detached grid when dropping them', () => {
const layout = withVersionKey({
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
GRID_ID: component('GRID_ID', 'GRID', ['ROW-stale']),
'ROW-stale': component('ROW-stale', 'ROW'),
'TABS-t': component('TABS-t', 'TABS', ['TAB-1']),
'TAB-1': component('TAB-1', 'TAB'),
});
const repaired = removeUnreachableComponents(layout);
expect(repaired['ROW-stale']).toBeUndefined();
expect(repaired.GRID_ID.children).toEqual([]);
expect(layout.GRID_ID.children).toEqual(['ROW-stale']);
});
test('returns the layout untouched when there is no root to walk from', () => {
const layout: Layout = { 'ROW-a': component('ROW-a', 'ROW') };
expect(removeUnreachableComponents(layout)).toBe(layout);
});
test('leaves the layout untouched when root children is not an array', () => {
const layout = reachableLayout();
layout.ROOT_ID = {
...layout.ROOT_ID,
children: { 0: 'GRID_ID' } as unknown as string[],
};
expect(removeUnreachableComponents(layout)).toBe(layout);
});
test('tolerates non-array children below the root', () => {
const layout = reachableLayout();
layout['ROW-a'] = {
...layout['ROW-a'],
children: 'CHART-a' as unknown as string[],
};
expect(() => removeUnreachableComponents(layout)).not.toThrow();
});

View File

@@ -0,0 +1,225 @@
/**
* 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 { DashboardComponent } from '../types';
import {
CHART_TYPE,
DASHBOARD_GRID_TYPE,
HEADER_TYPE,
MARKDOWN_TYPE,
ROW_TYPE,
TAB_TYPE,
TABS_TYPE,
} from './componentTypes';
import {
DASHBOARD_GRID_ID,
DASHBOARD_HEADER_ID,
DASHBOARD_ROOT_ID,
DASHBOARD_VERSION_KEY,
GRID_COLUMN_COUNT,
GRID_DEFAULT_CHART_WIDTH,
} from './constants';
import newComponentFactory, { DashboardEntity } from './newComponentFactory';
// HEADER_ID is dashboard metadata rather than a rendered child, GRID_ID is
// retained (emptied) and detached when a dashboard uses top-level tabs, and
// DASHBOARD_VERSION_KEY is a version string rather than a component.
const RESERVED_IDS = new Set<string>([
DASHBOARD_GRID_ID,
DASHBOARD_HEADER_ID,
DASHBOARD_ROOT_ID,
DASHBOARD_VERSION_KEY,
]);
// containers that accept both new rows and headers
const REATTACH_CONTAINER_TYPES = new Set<string>([
DASHBOARD_GRID_TYPE,
TAB_TYPE,
]);
const childrenOf = (component: unknown): string[] => {
const children = (component as DashboardComponent | undefined)?.children;
return Array.isArray(children)
? children.filter((id): id is string => typeof id === 'string')
: [];
};
const isComponent = (value: unknown): value is DashboardComponent =>
typeof value === 'object' && value !== null;
/**
* Drop components that cannot be reached from ROOT_ID, reattaching detached
* charts, markdown and headers instead of dropping them.
*
* Detached components never render, but they survive in position_json and stay
* visible to code that walks every layout entry or trusts the stale `parents`
* of a node. A detached subtree containing a cycle is what crashes the filter
* scope modal with "Maximum call stack size exceeded".
*
* A detached chart keeps its layout id and is moved into new rows in the deepest
* container of its stored `parents` that is still reachable, falling back to
* the first top-level container. Only the tail of a stale chain is gone, so its
* reachable prefix still says e.g. which tab the chart came from. Dropping it would lose the chart's cross-filter
* configuration, and a chart missing from the charts payload (e.g. archived
* with SOFT_DELETE) could not be re-added, so the next save would drop its
* dashboard membership. A chart that is also placed reachably is not duplicated.
*
* Detached markdown and headers are reattached the same way, since their text
* lives nowhere but position_json. Markdown goes into the new rows; a header,
* which cannot be a row child, goes directly into the container.
* Mirrors `superset/dashboards/layout.py`.
*/
export default function removeUnreachableComponents<
T extends DashboardComponent,
>(layout: Record<string, T>): Record<string, T | DashboardEntity> {
const root = layout[DASHBOARD_ROOT_ID];
if (!isComponent(root) || !Array.isArray(root.children)) {
return layout;
}
// path from ROOT_ID to each reachable id
const paths = new Map<string, string[]>();
const stack: [string, string[]][] = [[DASHBOARD_ROOT_ID, []]];
while (stack.length) {
const [id, parentPath] = stack.pop() as [string, string[]];
// doubles as the cycle guard: an id already seen is never expanded twice
if (!paths.has(id)) {
const path = [...parentPath, id];
paths.set(id, path);
childrenOf(layout[id]).forEach(childId => {
if (isComponent(layout[childId])) {
stack.push([childId, path]);
}
});
}
}
const reachable = new Set(paths.keys());
const unreachable = Object.keys(layout).filter(
id =>
isComponent(layout[id]) && !reachable.has(id) && !RESERVED_IDS.has(id),
);
if (!unreachable.length) {
return layout;
}
const placedChartIds = new Set<number | undefined>(
[...reachable]
.filter(id => layout[id].type === CHART_TYPE)
.map(id => layout[id].meta?.chartId),
);
const rescued: [string, T][] = [];
unreachable.forEach(id => {
const component = layout[id];
if (component.type === MARKDOWN_TYPE || component.type === HEADER_TYPE) {
rescued.push([id, component]);
return;
}
const chartId = component.meta?.chartId;
if (
component.type === CHART_TYPE &&
chartId !== undefined &&
!placedChartIds.has(chartId)
) {
placedChartIds.add(chartId);
rescued.push([id, component]);
}
});
const next: Record<string, T | DashboardEntity> = { ...layout };
unreachable.forEach(id => {
delete next[id];
});
// a detached reserved id is kept, but must not reference the dropped nodes
RESERVED_IDS.forEach(id => {
const component = next[id];
if (
!reachable.has(id) &&
isComponent(component) &&
component.children?.length
) {
next[id] = { ...component, children: [] };
}
});
// mirrors findFirstParentContainerId; the path is built here rather than
// read from `parents`, which may be stale or missing
const [firstId] = childrenOf(layout[DASHBOARD_ROOT_ID]);
const fallbackPath =
layout[firstId]?.type === TABS_TYPE
? [DASHBOARD_ROOT_ID, firstId, childrenOf(layout[firstId])[0]]
: [DASHBOARD_ROOT_ID, firstId];
const homePath = (component: T): string[] => {
const parents = Array.isArray(component.parents) ? component.parents : [];
for (let i = parents.length - 1; i >= 0; i -= 1) {
const path = paths.get(parents[i]);
if (path && REATTACH_CONTAINER_TYPES.has(layout[parents[i]].type)) {
return path;
}
}
return fallbackPath;
};
const groups = new Map<string, [string[], [string, T][]]>();
rescued.forEach(([componentKey, component]) => {
const path = homePath(component);
const key = path.join('/');
const group = groups.get(key) ?? [path, []];
group[1].push([componentKey, component]);
groups.set(key, group);
});
groups.forEach(([rowParents, components]) => {
const containerId = rowParents[rowParents.length - 1];
const container = containerId ? next[containerId] : undefined;
if (!isComponent(container)) {
return;
}
const containerChildren = childrenOf(container);
let row: DashboardEntity | undefined;
let rowWidth = 0;
components.forEach(([componentKey, component]) => {
if (component.type === HEADER_TYPE) {
containerChildren.push(componentKey);
next[componentKey] = { ...component, parents: rowParents.slice() };
row = undefined;
return;
}
const { width: rawWidth } = component.meta ?? {};
const width =
typeof rawWidth === 'number' && rawWidth > 0
? rawWidth
: GRID_DEFAULT_CHART_WIDTH;
if (!row || rowWidth + width > GRID_COLUMN_COUNT) {
row = newComponentFactory(ROW_TYPE, undefined, rowParents.slice());
next[row.id] = row;
containerChildren.push(row.id);
rowWidth = 0;
}
row.children.push(componentKey);
next[componentKey] = { ...component, parents: [...rowParents, row.id] };
rowWidth += width;
});
next[containerId] = { ...container, children: containerChildren };
});
return next;
}

View File

@@ -168,3 +168,20 @@ describe('updateComponentParentsList with bad inputs', () => {
).not.toThrow();
});
});
test('skips a back-edge to an ancestor instead of recursing forever', () => {
const layout: Record<
string,
{ id: string; children?: string[]; parents?: string[] }
> = {
root: { id: 'root', children: ['row'] },
row: { id: 'row', children: ['col'] },
col: { id: 'col', children: ['row'] },
};
expect(() =>
updateComponentParentsList({ currentComponent: layout.root, layout }),
).not.toThrow();
expect(layout.row.parents).toEqual(['root']);
expect(layout.col.parents).toEqual(['root', 'row']);
});

View File

@@ -44,7 +44,12 @@ export default function updateComponentParentsList({
if (Array.isArray(currentComponent.children)) {
currentComponent.children.forEach(childId => {
if (layout[childId]) {
if (parentsList.includes(childId)) {
// A back-edge to an ancestor would recurse forever.
logging.warn(
`The component ${childId} is its own ancestor in the current layout. Skipping this component`,
);
} else if (layout[childId]) {
// eslint-disable-next-line no-param-reassign
layout[childId] = {
...layout[childId],

View File

@@ -29,6 +29,7 @@ import {
VizType,
} from '@superset-ui/core';
import ChartContainerComponent from 'src/explore/components/ExploreChartPanel';
import { ExploreStandaloneMode } from 'src/explore/constants';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
jest.mock('./StandaloneDownloadControl', () => ({
@@ -203,7 +204,7 @@ describe('ChartContainer', () => {
test('does not render standalone download control when show_download is absent', () => {
const props = createProps({
standalone: true,
standalone: ExploreStandaloneMode.HideNav,
can_download: true,
});
@@ -218,7 +219,7 @@ describe('ChartContainer', () => {
window.history.replaceState({}, '', '?show_download=0');
const props = createProps({
standalone: true,
standalone: ExploreStandaloneMode.HideNav,
can_download: true,
});
@@ -233,7 +234,7 @@ describe('ChartContainer', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
standalone: true,
standalone: ExploreStandaloneMode.HideNav,
can_download: true,
});
@@ -248,7 +249,7 @@ describe('ChartContainer', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
standalone: true,
standalone: ExploreStandaloneMode.HideNav,
can_download: false,
});
@@ -263,7 +264,7 @@ describe('ChartContainer', () => {
window.history.replaceState({}, '', '?show_download=1');
const props = createProps({
standalone: false,
standalone: ExploreStandaloneMode.None,
can_download: true,
});

View File

@@ -29,8 +29,10 @@ import {
JsonObject,
getExtensionsRegistry,
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { ExploreStandaloneMode } from 'src/explore/constants';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import ChartContainer from 'src/components/Chart/ChartContainer';
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
@@ -88,7 +90,7 @@ export interface ExploreChartPanelProps {
vizType: string;
form_data: QueryFormData;
ownState?: JsonObject;
standalone?: boolean;
standalone?: number;
force?: boolean;
timeout?: number;
chartIsStale?: boolean;
@@ -255,7 +257,16 @@ const ExploreChartPanel = ({
);
useEffect(() => {
updateQueryContext();
updateQueryContext().catch(error => {
// Best-effort backfill; the chart renders either way. A 403 is expected
// (no write access, or a managed chart), so it stays at debug.
const message = 'Skipped background query context backfill';
if (error?.status === 403) {
logging.debug(message, error);
} else {
logging.warn(message, error);
}
});
}, [updateQueryContext]);
useEffect(() => {
@@ -503,7 +514,7 @@ const ExploreChartPanel = ({
[gutterMargin],
);
if (standalone) {
if (standalone === ExploreStandaloneMode.HideNav) {
// dom manipulation hack to get rid of the bootstrap theme's body background
const standaloneClass = 'background-transparent';
const bodyClasses = document.body.className.split(' ');

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