Compare commits

...
Author SHA1 Message Date
rusackasandClaude Opus 4.8 4ca609c0a4 test(models): add direct unit tests for validate_rendered_expression
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 23:45:41 -07:00
rusackasandClaude Opus 4.8 4c1e7d2ee2 fix(dataset): convert rendered-expression security errors, dedupe guest RLS cache key
- validate_rendered_expression now converts every failure mode (parse,
  set operation, disallowed sub-query, sanitization) to
  QueryObjectValidationError, matching validate_stored_expression_at_query_time,
  instead of letting SupersetSecurityException escape uncaught from its
  call sites in connectors/sqla/models.py and models/helpers.py.
- The unresolved-RLS-predicates cache-key fallback now keys guest users
  by a hash of their token's RLS rules/resources instead of
  get_user_id(), which returns None for every guest token and was
  collapsing distinct guest RLS contexts onto the same cache entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:21:42 -07:00
rusackasandClaude Opus 4.8 c69241bcdf fix(dataset): reduce complexity, dedupe repoint access checks
Split _validate_dataset_source into focused helpers to satisfy ruff's
C901 complexity check, and stop re-running raise_for_access when a
database repoint already covers the same (db, table) check -- both the
new physical-repoint check and the SQL-access re-check on database_changed
were firing redundant/duplicate calls against the same target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:20:27 -07:00
Evan RusackasandClaude Opus 4.8 d5d3663088 test(dataset): update integration tests for RLS cache key and alert auth; strengthen alert query validation
Require exactly one alert statement and use strict dataset matching for alert authorization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:20:27 -07:00
Evan RusackasandClaude Opus 4.8 2c5b655595 fix(dataset): re-validate rendered expressions and RLS application
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:20:01 -07:00
Evan RusackasandClaude Opus 4.8 ed8dd37dd1 fix(dataset): validate dataset repoint, fetch-values predicate, and alert queries
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:19:19 -07:00
885f00130c fix(sqla): drop stale main_dttm_col from dttm_cols when non-temporal (#30510) (#41964)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-11 19:38:53 -07:00
584466e02b fix(sql-lab): improved SQL statement parsing and validation (#42928)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 19:35:43 -07:00
d570335f67 fix: bind permission-sync task to user id, use per-user RLS cache sentinel on parse failure (#42938)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 19:35:40 -07:00
a0099af88f fix(deck.gl): dismiss custom tooltips on hover-out (#43075)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 19:32:43 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8bd25b3fed chore(deps-dev): bump the typescript-eslint group across 1 directory with 2 updates (#43038)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 16:28:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>rusackasClaude Opus 4.8
0a2cfb9fba chore(deps-dev): bump oxfmt from 0.61.0 to 0.62.0 in /superset-frontend (#43046)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 16:28:48 -07:00
5248367587 fix: verify database access before dataset repointing, and tab ownership before tab-state updates (#42936)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 16:07:58 -07:00
bdf8ce69ab feat(Reports&Alerts): remove Selenium support, require Playwright (#43028)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-08-11 16:06:26 -07:00
Dang DaoandGitHub d91ca50872 perf(charts): reuse datasource in query context (#42476) 2026-08-11 14:31:47 -07:00
f6077eeaa1 fix(dataset): validate stored column and metric expressions at query time (#42087)
Signed-off-by: sha174n <shaitan@zx48.net>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-11 21:48:26 +01:00
0d57c5ab50 fix(dashboard): consistently apply the datasource-access check when serializing member charts and datasets (#42716)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 21:47:47 +01:00
edf30c7019 chore: add logging event for drill to detail (#42563)
Co-authored-by: Ayush Kumar Sharaf <ayush.sharaf@314ecorp.com>
2026-08-11 13:47:00 -07:00
3ded398a1f fix(chart): dataset dropdown sorted by name (#42475)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-08-11 13:45:48 -07:00
63a81c2125 fix(dashboard): remeasure chart description height after markdown render (#41536)
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:44:48 -07:00
af258c21d0 fix(mixed-chart): Mixed chart secondary yaxis title position misplaced fix (#42713)
Co-authored-by: Mallikarjuna Reddy N <MallikarjunaReddy.Nimmakayala@gevernova.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 13:13:34 -07:00
9086bab2c2 ci(e2e): stop gunicorn worker recycling mid-run; slow-test budget for mixed-chart filter spec (#42867)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:43:51 -07:00
4c5a3356f3 fix(dashboard): redirect using sanitized slug from save response (#42853)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: rusackas <evan@rusackas.com>
2026-08-11 12:43:24 -07:00
998c408686 fix(rls): return a descriptive error for duplicate rule names (#42819)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: rusackas <evan@rusackas.com>
2026-08-11 12:41:24 -07:00
Joe LiandGitHub a3d58d1211 fix(ci): use current actions for Docker refresh (#42482) 2026-08-11 12:04:09 -07:00
7b32b870e1 chore: Fix/remove hardcode of admin role (#27779)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: rusackas <evan@rusackas.com>
2026-08-11 10:26:50 -07:00
3cd4150539 chore(deps): bump sqlglot from 30.15.0 to 30.16.0 (#43029)
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-08-11 10:16:44 -07:00
2feb6c2bb5 refactor(deletion-retention): add declarative purge policies (#42888)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:55:36 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5767c3981d chore(deps): bump the storybook group across 1 directory with 2 updates (#43037)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 09:48:45 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ec6b2d2347 chore(deps-dev): bump oxfmt from 0.61.0 to 0.62.0 in /superset-websocket (#43033)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 09:48:42 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aade32cad8 chore(deps-dev): bump the typescript-eslint group across 1 directory with 3 updates (#43039)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 09:48:38 -07:00
d6771e96d2 fix(core): let UnsavedChangesModal use Ant Design's automatic z-index stacking (#42548)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 09:42:49 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
305638e98c chore(deps-dev): bump baseline-browser-mapping from 2.11.11 to 2.11.12 in /superset-frontend (#43048)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 09:42:16 -07:00
5099b6ffa9 fix(export): derive chart_hashes and zip from a single chart list (#42946)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 09:35:59 -07:00
bc436bacad chore(security): warn when legacy encryption engine is configured (#42937)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-08-11 09:35:46 -07:00
e31a894be6 fix(common): remove unsupported cross join mode from left_join_df (#43016)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 09:35:16 -07:00
553808daa3 fix(reports): make webhook notification request timeout configurable (#43018)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 09:35:01 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
544756a631 chore(deps): bump github/codeql-action/analyze from 4.37.5 to 4.37.6 (#43040)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 09:34:49 -07:00
c4e8a9da90 test(explore): add end-to-end regression test for DatasourcePanel through the real AutoSizer + react-window List pipeline (#43023)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-11 09:34:06 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
47adbe31d5 chore(deps-dev): bump typescript-eslint from 8.65.0 to 8.66.0 in /superset-websocket (#43034)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:43:16 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
64165fbdae chore(deps-dev): bump @typescript-eslint/parser from 8.65.0 to 8.66.0 in /superset-websocket (#43035)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:43:12 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
581fb876e4 chore(deps-dev): bump oxfmt from 0.61.0 to 0.62.0 in /docs (#43042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:43:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
29e8e396ee chore(deps-dev): bump the storybook group in /superset-frontend with 5 updates (#43045)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:43:05 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8bbf0cfc20 chore(deps-dev): bump oxlint from 1.76.0 to 1.77.0 in /superset-frontend (#43049)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:43:01 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
497c73e837 chore(deps): bump github/codeql-action/init from 4.37.5 to 4.37.6 (#43044)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:42:53 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a5a6f5a5b1 chore(deps): bump ws from 8.21.1 to 8.21.2 in /superset-websocket (#43036)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:42:50 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
156a59a190 chore(deps): bump swagger-ui-react from 5.32.11 to 5.32.12 in /docs (#43041)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:42:46 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
590003cbee chore(deps): bump baseline-browser-mapping from 2.11.11 to 2.11.12 in /docs (#43043)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:42:43 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
579011dea9 chore(deps): bump nanoid from 6.0.0 to 6.0.1 in /superset-frontend (#43050)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:42:39 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
65d808a5fe chore(deps): bump markdown-to-jsx from 9.10.1 to 9.10.2 in /superset-frontend (#43052)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 06:42:34 -07:00
5ee279363c fix(mcp): reject list values for ne on relationship filters (#42944)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 00:42:13 -07:00
34b2d3a533 fix(rls): match null-schema datasets in predicate lookup (#41426)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:58:51 -07:00
7157b752ee fix(pie): apply percentage number format to labels (#42880)
Co-authored-by: Joe Li <joe@preset.io>
2026-08-10 19:36:03 -07:00
d84a00f3c6 feat(dashboard): handle empty chart query context in Excel export (#42284)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
2026-08-10 21:12:08 -04:00
bfa1e775c4 fix(theming): guard ensureStaticPrefix against null/undefined input (#43019)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 17:41:33 -07:00
265269018e fix(mcp): require issuer-aware resolver for multi-issuer JWT, fix sanitizer input ordering (#42935)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:56:46 -07:00
5f204a9af2 fix(frontend): remove any type and duplicate route in routes.tsx (#43014)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:51:58 -07:00
0c09cf087d fix(reports): translate execute-now success message (#43012)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:51:05 -07:00
651f75b1e8 docs(dashboard): document native filters on semantic views (#43011)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:50:34 -07:00
e48512568d docs(dashboard): document the expand_all_slices metadata flag (#43010)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:50:03 -07:00
4d0eeb7d86 docs: document FAB_API_SWAGGER_UI_SUPERSET_APP_ROOT config flag (#43009)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 15:49:47 -07:00
Elizabeth ThompsonandGitHub fdfc49461f fix(sqllab): wrap process_template() in format_sql to prevent raw UndefinedError leak (#42917) 2026-08-10 15:02:10 -07:00
2eb1e04283 fix: hydrate JWT user before list API filters (#39946)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-10 14:18:28 -07:00
bc85f1eacf fix(database): resolve default schema when file upload schema is empty (#42603)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:56:47 -07:00
Gabriel Torres RuizandGitHub 0dedc55f38 fix(dashboard): changing a theme no longer discards unsaved edits or reloads charts 2026-08-10 16:52:43 -03:00
178d9c4ceb fix(explore): stacked Timeseries Bar total excludes the sort-only metric (#42881)
Co-authored-by: Joe Li <joe@preset.io>
2026-08-10 12:40:18 -07:00
Luc VerdierandGitHub 347774f9b8 fix(explore): stop the viz switcher from overflowing the control panel (#42911) 2026-08-10 12:36:23 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
776f70bf63 chore(deps): bump ioredis from 5.11.1 to 6.0.0 in /superset-websocket (#42959)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 12:35:56 -07:00
572f86c3c3 fix(deckgl): normalize legend swatch alpha channel to 0-1 for CSS rgba() (#42942)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 12:35:24 -07:00
Sepuri Sai KrishnaandGitHub 50c811280e fix(lru-cache): don't evict another entry when overwriting an existing key (#42409) 2026-08-10 12:29:20 -07:00
BandarandGitHub 9d6832c927 feat(formatting): add network throughput number formats for bits/s and bytes/s (#42437) 2026-08-10 12:29:05 -07:00
b6504eb111 fix(sql): resolve schema/catalog-qualified table references in CTE detection (#42717)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 12:28:31 -07:00
d62a9eeda6 fix(calendar): clean up d3-tip tooltips (#41589)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:28:06 -07:00
651c046e69 fix(deckgl): remove visibility condition in deckgl fill color (#42712)
Co-authored-by: Damian Pendrak <dpendrak@gmail.com>
2026-08-10 20:58:44 +02:00
Joe LiandGitHub d9ae0ec2fc fix(deckgl): hide legend when Legend Position is None (#42402) 2026-08-10 11:24:35 -07:00
varadendrasimha511andGitHub b98e157104 fix(sqla): preserve float precision when mixing int/float values in IN filters (#42752) 2026-08-10 11:12:43 -07:00
4defdbabd2 fix(dashboard): force-render virtualized charts before client-side export (#42561)
Signed-off-by: Jenwit Amonpongitsara <jenwit.amonpongitsara@agoda.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: rusackas <evan@rusackas.com>
2026-08-10 10:57:21 -07:00
7dc2afd377 docs(cache): document native filter option cache warm-up strategy (#42953)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 10:28:36 -07:00
5bcd9cd078 docs(exploring-data): document CSV/XLSX download in drill modals (#42952)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 10:28:14 -07:00
341ca94ba2 docs(security): document the read-only Subject REST API (#42951)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 10:27:54 -07:00
4b68da82a7 docs: document number format presets, including distance/length units (#42950)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 10:27:22 -07:00
a9eab4a0df docs: document color picker for conditional formatting rule colors (#42948)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 10:26:37 -07:00
f60370b695 docs: document CACHE_REDIS_SOCKET_TIMEOUT/CONNECT_TIMEOUT config keys (#42947)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 10:26:18 -07:00
c02dc7780b docs(mcp): add update_dashboard to write-tools enumeration (#42943)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 10:25:03 -07:00
Mehmet Salih YavuzandGitHub daf1718b44 fix(embedded): redact database errors in API responses to guest users (#42796) 2026-08-10 18:45:20 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b815640b8f chore(deps): bump actions/setup-java from 5.6.0 to 5.7.0 (#42968)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 07:54:41 -07:00
Antonio RiveroandGitHub 9e2cb69a39 feat(database): identify configured database connection errors (#42996) 2026-08-10 14:14:42 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
db88abe0bb chore(deps-dev): bump globals from 17.8.0 to 17.9.0 in /superset-websocket (#42958)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 04:53:29 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f53f634f23 chore(deps): bump antd from 6.5.2 to 6.5.3 in /docs (#42961)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 04:53:13 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
916c50284b chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#42965)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:39:14 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6a2e20cdb7 chore(deps): bump zizmorcore/zizmor-action from 0.6.1 to 0.6.2 (#42960)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:29:49 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9719c7de24 chore(deps-dev): bump globals from 17.8.0 to 17.9.0 in /docs (#42962)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:29:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
346dbd6a98 chore(deps): bump js-yaml from 5.2.2 to 5.2.3 in /docs (#42963)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:28:29 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
73478c5274 chore(deps): bump baseline-browser-mapping from 2.11.8 to 2.11.11 in /docs (#42964)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:28:25 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d7edf610a9 chore(deps-dev): bump @babel/types from 7.29.7 to 7.29.8 in /superset-frontend in the babel group (#42966)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:28:20 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e12818897d chore(deps): bump github/codeql-action/init from 4.37.3 to 4.37.5 (#42967)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:28:16 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d41032b70a chore(deps): bump Kesin11/actions-timeline from 3.1.1 to 3.2.0 (#42969)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:28:10 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
10cf2e20f9 chore(deps-dev): bump eslint-plugin-jest-dom from 5.7.0 to 5.10.1 in /superset-frontend (#42970)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:28:05 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2be726ddad chore(deps-dev): bump tsx from 4.23.1 to 4.23.5 in /superset-frontend (#42971)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:28:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ee5fb095da chore(deps-dev): bump baseline-browser-mapping from 2.11.8 to 2.11.11 in /superset-frontend (#42972)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:27:58 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4c9bd69eb4 chore(deps): bump antd from 6.5.2 to 6.5.3 in /superset-frontend (#42973)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:27:55 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7cd6c74757 chore(deps-dev): bump @types/lodash from 4.17.24 to 4.17.25 in /superset-frontend (#42974)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 01:27:51 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3539c41dab chore(deps): bump cryptography from 49.0.0 to 50.0.0 (#42906)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-09 23:42:10 -07:00
c09d5bf6a9 fix(helm): add missing task modules to default CeleryConfig imports (#42945)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-08-09 22:06:45 -07:00
Đỗ Trọng HảiandGitHub f8925cc726 feat(build): reinstate no-restricted-imports lint rule for lodash (#42940) 2026-08-10 09:28:41 +07:00
Jetha ChanGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
fea84e81ef fix(tech-debt): measure oxlint metrics against the project's ruleset (#42955)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-10 09:26:15 +07:00
Amin GhadersohiandGitHub b413060030 chore(deps): restore redis floor to >=5.0.0 (#42915) 2026-08-09 17:41:54 -07:00
260 changed files with 15274 additions and 4935 deletions
+11 -8
View File
@@ -201,18 +201,23 @@ cypress-run-all() {
# navigation flow under E2E. We diverge from the entrypoint on:
# --timeout 120: heavy dashboard import/export specs exceed the 60s
# default
# --max-requests / --max-requests-jitter: recycle the worker under
# test load to avoid leaks accumulating across the run
# superset.app:create_app(): explicit factory so we don't depend on
# FLASK_APP being exported
#
# No --max-requests, matching the entrypoint's default of 0 (recycling
# off). With a single worker a recycle takes the whole backend offline for
# the graceful-timeout drain — browser keep-alive connections hold it open
# for the full 30s — plus ~5s of app boot. A run issues ~3800 requests in
# ~8 minutes, so recycling every 500 produced seven ~35s outages per run
# and flaked whichever specs happened to navigate into one. Lowering
# --graceful-timeout is not enough: a dashboard load plus chart render
# needs 6-10s, which still lands inside the window.
nohup gunicorn \
--bind "127.0.0.1:$port" \
--workers 1 \
--worker-class gthread \
--threads 20 \
--timeout 120 \
--max-requests 500 \
--max-requests-jitter 50 \
--access-logfile - \
--error-logfile - \
"superset.app:create_app()" \
@@ -294,16 +299,14 @@ playwright-run() {
export PLAYWRIGHT_BASE_URL
# See cypress-run-all() above for the args rationale (1 worker × 20
# gthread threads matching docker/entrypoints/run-server.sh, plus a
# 120s timeout and request-recycling for heavy E2E load).
# gthread threads matching docker/entrypoints/run-server.sh, a 120s
# timeout for heavy E2E load, and why worker recycling is off).
nohup gunicorn \
--bind "127.0.0.1:$port" \
--workers 1 \
--worker-class gthread \
--threads 20 \
--timeout 120 \
--max-requests 500 \
--max-requests-jitter 50 \
--access-logfile - \
--error-logfile - \
"superset.app:create_app()" \
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
check-python-deps:
runs-on: ubuntu-26.04
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
+2 -2
View File
@@ -64,7 +64,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -75,6 +75,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
# You cannot use a liccheck.ini file in this workflow.
runs-on: ubuntu-slim
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
+1 -1
View File
@@ -221,6 +221,6 @@ jobs:
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: "temurin"
java-version: "11"
@@ -46,4 +46,4 @@ jobs:
run: bash .github/workflows/github-action-validator.sh
- name: Check for security issues on GHA workflows
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -195,6 +195,6 @@ jobs:
if: always()
runs-on: ubuntu-26.04
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
@@ -108,8 +108,18 @@ jobs:
fetch-depth: 0
persist-credentials: false
# Keep workflow tooling on the triggering revision. Release tags can
# contain action pins that no longer satisfy the repository allowlist.
- name: Checkout workflow actions
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
path: workflow-source
persist-credentials: false
sparse-checkout: .github/actions
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
uses: ./workflow-source/.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -122,7 +132,7 @@ jobs:
node-version: 20
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
uses: ./workflow-source/.github/actions/setup-supersetbot/
- name: Rebuild and push
env:
@@ -171,7 +181,7 @@ jobs:
--repo "$REPOSITORY" \
--title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \
--label "infra:container" \
--label "bug" \
--label "#bug" \
--body "The weekly Docker base-image refresh failed for release \`${LATEST_RELEASE}\`. Published images may be missing upstream base-layer security patches until this is resolved.
Failed run: ${RUN_URL}"
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
ports:
- 16379:6379
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
name: Build & Deploy
runs-on: ubuntu-26.04
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
@@ -76,7 +76,7 @@ jobs:
node-version-file: "./docs/.nvmrc"
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: "zulu"
java-version: "21"
+1 -1
View File
@@ -355,6 +355,6 @@ jobs:
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
@@ -76,6 +76,6 @@ jobs:
if: always()
runs-on: ubuntu-26.04
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
+1 -1
View File
@@ -209,6 +209,6 @@ jobs:
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
@@ -22,7 +22,7 @@ jobs:
lint-test:
runs-on: ubuntu-slim
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
+1 -1
View File
@@ -178,6 +178,6 @@ jobs:
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
@@ -263,6 +263,6 @@ jobs:
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
@@ -166,6 +166,6 @@ jobs:
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
@@ -105,7 +105,7 @@ jobs:
contents: read
actions: read
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
+1 -1
View File
@@ -161,6 +161,6 @@ jobs:
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
expand-composite-actions: true
+64 -2
View File
@@ -24,7 +24,35 @@ assists people when migrating to a new version.
## Next
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
- [42087](https://github.com/apache/superset/pull/42087): Stored calculated-column and metric expressions are validated when a query is built, under the same sub-query policy already applied to adhoc expressions. Previously only the dataset update path checked them on save, so expressions written by v1 import, by dataset duplication, or before that check existed were never validated. Since `ALLOW_ADHOC_SUBQUERY` defaults to `False` (see [19242](https://github.com/apache/superset/pull/19242)), a dataset whose stored expression contains a sub-query works before upgrading and afterwards fails at chart render with `Custom SQL fields cannot contain sub-queries.` There is no migration step, and the error does not name the offending dataset column, so audit stored expressions before upgrading: either rewrite them without the sub-query, or set `ALLOW_ADHOC_SUBQUERY = True` to keep the previous behaviour for both stored and adhoc expressions.
### Selenium support removed — Playwright is now required for screenshots
Selenium support has been removed. **Playwright is now required** for all
report and thumbnail screenshot generation. Install it with:
```bash
pip install playwright && playwright install chromium
```
**Breaking config changes:**
- `PLAYWRIGHT_REPORTS_AND_THUMBNAILS` feature flag removed (Playwright is the only backend now)
- `WEBDRIVER_TYPE` config key removed (Playwright always uses Chromium)
- `WEBDRIVER_CONFIGURATION` config key removed (Selenium-only)
- `SCREENSHOT_PAGE_LOAD_WAIT` config key removed (Selenium-only)
- `SCREENSHOT_SELENIUM_RETRIES` config key removed (Selenium-only)
- `SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE` config key removed (Selenium-only)
- `SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE` config key removed (Selenium-only)
- `MachineAuthProvider.authenticate_webdriver()` removed; use `authenticate_browser_context()` instead
**What operators should do before upgrading:**
1. Install Playwright: `pip install playwright && playwright install chromium`
2. Remove any references to the removed config keys from custom `superset_config.py`
3. If you subclassed `MachineAuthProvider`, remove any `authenticate_webdriver` override and migrate auth logic to `authenticate_browser_context`
### Soft delete is on by default, and purging is live
@@ -109,6 +137,28 @@ Behavior changes to be aware of:
if charts never mount. Thumbnails and non-report screenshots keep their
previous behavior.
### Embedded (guest token) API responses no longer echo database errors
API responses served to a guest-token principal now carry a generic
`An error occurred while fetching the data.` in place of the underlying error
(`You don't have permission to access this resource.` on a 401/403), and drop
the `stacktrace` and error `extra` payloads. Engine errors routinely quote
catalog, schema, table and column names of the warehouse, which embedded
viewers should not see. Errors Superset authors itself — access denials, OAuth2
redirects, timeouts, payload validation — keep their message and type, though
their `extra` is still reduced to the fields the client needs. Responses to
every non-guest principal are unchanged, and the full error is still logged
server-side.
### `UnsavedChangesModal` no longer accepts a `zIndex` prop
`@superset-ui/core`'s `UnsavedChangesModal` dropped its `zIndex` prop (and the
hardcoded default it fed) in favor of letting Ant Design's own stacking
handle placement. Callers passing `zIndex` to override the modal's layering
will now get a TypeScript error and must remove the prop; keeping a manual
override was exactly the footgun this change removes (see #42510). No
callers in the Superset frontend codebase itself passed this prop.
### Principal listing APIs now honour related-field filters
Two authorization-related listing behaviors changed for API clients. Neither
@@ -157,12 +207,24 @@ A new dashboard action exports every chart's data to a single multi-sheet
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`, and
`EXCEL_EXPORT_TABLE_VIZ_TYPES`.
`EXCEL_EXPORT_LINK_TTL_SECONDS`, `EXCEL_EXPORT_S3_CLIENT_KWARGS`,
`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]`.
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
types (`table`, `big_number_total`, `big_number`, `pie`) the export rebuilds a
query context from the chart's saved form data so those charts still export.
The rebuild is a single-query mapping and does **not** reproduce plugin
post-processing (pivot, rolling, forecast) or multi-query charts, so any chart of
another type without a saved query context is skipped and listed in the email for
the user to re-save. To cover those types, set `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`
to a callable that receives the chart's form data and returns a query-context
payload (or `None` to fall back to the built-in rebuild) — for example one backed
by a service that runs the chart's real frontend `buildQuery`.
A second mode, **Export Images to Excel**, embeds non-table charts as rendered
images (which viz types stay tabular is controlled by
`EXCEL_EXPORT_TABLE_VIZ_TYPES`). It renders through the headless webdriver, so the
@@ -175,6 +175,16 @@ ALERT_REPORTS_WEBHOOK_HTTPS_ONLY = True
When enabled, Superset rejects webhook configurations that use `http://` URLs.
#### Request Timeout
Webhook deliveries use a socket timeout so a request can't hang forever if the webhook target is unreachable, which would otherwise leave the report schedule stuck in a `WORKING` state. Configure it with:
```python
ALERT_REPORTS_WEBHOOK_TIMEOUT = 60 # seconds
```
Set to `None` to disable the timeout (not recommended).
#### Retry Behavior
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff. Retries are bounded to roughly 120 seconds of cumulative wall-clock time (worst case ~210 seconds, because the bound is checked against the time elapsed before each attempt, so the final request can begin just under the limit and still run its full request timeout), after which the delivery is abandoned.
+62
View File
@@ -134,6 +134,50 @@ CELERY_CONFIG = CustomCeleryConfig
This will cache the top 5 most popular dashboards every hour. For other
strategies, check the `superset/tasks/cache.py` file.
### Warming Up Native Filter Options
Native filter Value-type dropdown option queries (e.g. `SELECT DISTINCT column FROM table`) are
cached the same way as chart data, via `DATA_CACHE_CONFIG`. However, the strategies above only warm
up chart render queries, so the first user to open a dashboard's filter dropdown after a cache entry
expires still triggers a fresh database query.
The `native_filter_options` strategy pre-populates the cache for these dropdown queries. It reads
each dashboard's `native_filter_configuration`, builds the same `filter_select` chart-data query the
frontend would send, and executes it as the configured `SUPERSET_CACHE_WARMUP_USER`:
```python
class CustomCeleryConfig(CeleryConfig):
beat_schedule = {
**CeleryConfig.beat_schedule,
'cache-warmup-native-filters': {
'task': 'cache-warmup',
'schedule': crontab(minute=0, hour=3), # daily at 03:00
'kwargs': {
'strategy_name': 'native_filter_options',
'dashboard_ids': [1, 2, 3],
},
},
}
```
Requirements and limitations:
- `SUPERSET_CACHE_WARMUP_USER` must be set to a user with access to the dashboards and datasets
referenced by the native filters.
- `DATA_CACHE_CONFIG` must use a backend that actually persists entries (Redis recommended); the
default `NullCache` discards writes, so warming has nothing to warm. The effective timeout also
needs to be positive — `NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT = -1` disables cache writes for these
queries entirely, even with a working backend.
- Schedule the warm-up at least as often as the effective native filter cache timeout (whichever of
`NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT`, the chart/dataset/database timeout, or `DATA_CACHE_CONFIG`'s
default applies). A looser schedule still leaves a window of cold, unwarmed queries between expiry
and the next run — the daily example above assumes a TTL of a day or more.
- Cache entries are warmed under the warm-up user's own cache partition, the same entry that user
would create by opening the filter dropdown manually. Users with a different role set or row-level
security context may still see a cache miss on first load.
- Cascading/dependent native filters and search-term variants of filter option queries are not
warmed by this strategy.
## Caching Thumbnails
This is an optional feature that can be turned on by activating its [feature flag](/admin-docs/configuration/configuring-superset#feature-flags) on config:
@@ -301,6 +345,24 @@ DISTRIBUTED_COORDINATION_CONFIG = {
}
```
By default, connections opened for `DISTRIBUTED_COORDINATION_CONFIG` (as well as
`GLOBAL_ASYNC_QUERIES_CACHE_BACKEND`, which uses the same `RedisCache`/`RedisSentinelCache`
backend) have no socket timeout. This can be overridden with `CACHE_REDIS_SOCKET_TIMEOUT` and
`CACHE_REDIS_SOCKET_CONNECT_TIMEOUT`, both in seconds:
```python
DISTRIBUTED_COORDINATION_CONFIG = {
"CACHE_TYPE": "RedisCache",
"CACHE_REDIS_HOST": "localhost",
"CACHE_REDIS_PORT": 6379,
"CACHE_REDIS_SOCKET_TIMEOUT": 5, # seconds
"CACHE_REDIS_SOCKET_CONNECT_TIMEOUT": 5, # seconds
}
```
These apply to `RedisSentinelCache` connections as well, covering both the sentinel-node
connections and the resolved master connection.
### Distributed Lock TTL
You can configure the default lock TTL (time-to-live) in seconds. Locks automatically expire after
@@ -307,6 +307,22 @@ to simplify the process of setting up a non-default root path across the service
In `docker/.env-local` set `SUPERSET_APP_ROOT` to the desired prefix and then bring the
services up with `docker compose up --detach`.
### Swagger UI
By default, Superset's Swagger UI and OpenAPI spec (enabled via `FAB_API_SWAGGER_UI`) are
served by Flask-AppBuilder and don't account for a non-root `APPLICATION_ROOT` prefix. If
you're running Superset behind a URL prefix and want the Swagger UI and OpenAPI spec to
resolve correctly, set:
```python
FAB_API_SWAGGER_UI_SUPERSET_APP_ROOT = True
```
in your `superset_config.py` file. This serves an `APPLICATION_ROOT`-aware Swagger UI and
OpenAPI spec at `/swagger/<version>` and `/api/<version>/_openapi` respectively, resolved
through the configured prefix. This flag only takes effect when `FAB_API_SWAGGER_UI` is
also enabled, and defaults to `False`.
## Custom OAuth2 Configuration
Superset is built on Flask-AppBuilder (FAB), which supports many providers out of the box
+45
View File
@@ -198,6 +198,51 @@ Available per-entity overrides are:
When an override is set, it replaces `SUBJECTS_RELATED_TYPES` for that picker. When it is `None`,
the picker inherits the global default.
#### Looking Up Subjects via API
Superset exposes a read-only REST API for resolving subjects:
```
GET /api/v1/security/subject/
```
The main use case is **id mapping** — given a user, role, or group id, callers (including
extensions) can look up the corresponding subject entity, and vice versa. Access is gated by
the `can_read` permission on the `Subject` resource, which is granted to **Admins only** by
default, since subjects enumerate every user, role, and group on the instance. Callers without
that permission receive a `403`. Only `GET` (list, get, info) is exposed — there is no create,
update, or delete, because subjects are derived automatically from users, roles, and groups and
kept in sync internally.
**Resolve the subject for a given principal id** using [Rison](https://github.com/Nanonid/rison)
query syntax:
```
GET /api/v1/security/subject/?q=(filters:!((col:user_id,opr:eq,value:5)))
GET /api/v1/security/subject/?q=(filters:!((col:role_id,opr:eq,value:3)))
GET /api/v1/security/subject/?q=(filters:!((col:group_id,opr:eq,value:2)))
```
**Filter by subject type or active status:**
```
GET /api/v1/security/subject/?q=(filters:!((col:type,opr:eq,value:1)))
GET /api/v1/security/subject/?q=(filters:!((col:active,opr:eq,value:!t)))
```
The `type` column is an integer enum: `1` for User, `2` for Role, `3` for Group
(`superset.subjects.types.SubjectType`).
**Search by label:**
```
GET /api/v1/security/subject/?q=(filters:!((col:label,opr:subject_all_text,value:finance)))
```
Each subject in the response includes flat scalar ids (`user_id`, `role_id`, `group_id`) rather
than a nested object, so callers can match directly on whichever id they already have — only the
id field matching the subject's `type` is populated; the others are `null`.
### Dashboard Access Control
Access to dashboards is managed via editors (subjects that have edit permissions to the dashboard).
+16
View File
@@ -277,6 +277,22 @@ second etc). Example:
}
```
## How do I expand all chart descriptions on a dashboard by default?
Charts can have a markdown description, set in the chart's **Edit chart properties** dialog, that's
hidden by default and toggled on a per-chart basis from the chart's context menu on a dashboard. If
you'd rather have every chart's description expanded by default when the dashboard loads, add the
`expand_all_slices` key to the dashboard JSON Metadata field:
```json
{
"expand_all_slices": true
}
```
Charts that have already been manually expanded or collapsed on the dashboard keep that per-chart
override (tracked in the `expanded_slices` key) regardless of the `expand_all_slices` setting.
## Does Superset work with [insert database engine here]?
The [Connecting to Databases section](/user-docs/databases/) provides the best
@@ -165,6 +165,31 @@ You can also certify metrics if you'd like for your team in this view.
- [Blog: Unlocking the Power of Virtual Datasets](https://preset.io/blog/unlocking-the-power-of-virtual-datasets-in-apache-superset/)
:::
### Native filters on semantic views
When the `SEMANTIC_LAYERS` feature flag is enabled, Superset can connect to external semantic layers
(such as dbt Semantic Layer or Cube) and expose their semantic views as data sources alongside your
regular Datasets. Semantic views can be used as filter targets when adding a native (dashboard) filter,
the same way a Dataset can.
To add a filter on a semantic view:
1. Open the dashboard, click the **⋮** (more options) menu, and select **Edit dashboard**.
2. Open the Filter Bar and click **+ Add/Edit Filters**.
3. Add a new filter and, in the datasource dropdown, select a semantic view. Semantic views are listed
alongside datasets and can be identified by their type.
4. Select one of the semantic view's dimensions in the **Column** field, the same way you'd select a
column on a dataset.
5. Configure the remaining filter options (filter type, default value, scope, etc.) and click **Save**.
Any chart on the dashboard that's powered by the same semantic view is filtered by the selected
dimension when the filter is applied.
:::note
Semantic views and native filter support for them are part of the experimental Semantic Layers
feature and require the `SEMANTIC_LAYERS` feature flag to be enabled.
:::
### Creating charts in Explore view
Superset has 2 main interfaces for exploring data:
@@ -303,6 +328,10 @@ Conditional formatting rules highlight cells based on their values. Rules can be
Each rule has a **"Use gradient"** toggle: enabled applies a varying opacity (lighter = further from threshold), disabled applies a solid fill at full opacity regardless of value.
Each rule's color is set with a full color picker rather than a fixed dropdown of presets. Pick any custom color, or use the **Colors** preset swatches, which reference theme tokens (success, warning, error, and their background variants) so a rule's color updates automatically if the active theme changes, including switching between light and dark mode.
When a rule targets a column with an active time comparison, a **Trend colors** preset also appears, letting you color cells green for an increase and red for a decrease (or the reverse).
#### HTML Rendering in Table Cells
Table chart cells can render raw HTML, enabling rich formatting such as hyperlinks, colored badges, and icons directly in the data. Enable this per-column in the chart's **Column Configuration** panel by toggling **Render HTML**.
+1
View File
@@ -129,3 +129,4 @@ The following URL parameters can be passed through the `urlParams` option in `da
- **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.
- **Row-level security** — pass `rls` rules in the guest token request to restrict which rows are visible to the embedded user.
- **Allowed domains** — restrict which host origins can embed a dashboard by setting **Allowed Domains** per-dashboard in the _Embed_ settings modal. Superset checks the request's `Referer` header against this list before serving the embedded view; an empty list allows any origin, so configure this explicitly for production.
- **Redacted errors** — API responses to a guest token report a generic `An error occurred while fetching the data.` instead of the underlying error, since engine errors quote catalog, schema, table and column names. Errors Superset raises itself — access denials, timeouts, payload validation — keep their message, and the full error is always available in the server logs.
@@ -352,6 +352,13 @@ The **Custom** time range picker accepts natural language expressions alongside
These expressions are evaluated at query time, so saved charts always display data relative to the current date.
### Downloading Drill to Detail and Drill By Results
The **Drill to detail** and **Drill by** modals, available from a chart's context menu, show the row-level
data behind a chart (or behind a specific data point, when one is selected). Use the **Download** button in
the modal's toolbar to export the underlying result set as CSV or Excel (XLSX) without leaving the modal —
the export isn't limited to the page currently visible in the table.
:::resources
- [Chart Walkthroughs](https://docs.preset.io/docs/chart-walkthroughs) - Detailed guides for most chart types
@@ -32,8 +32,13 @@ Notes on the generated workbook:
Excel's 31-character limit; the chart id keeps names unique).
- Charts nested in tabs are included.
- Data reflects the dashboard's active filter state at the time of export.
- A chart with no saved query context is skipped and listed in the email; open
the chart in Explore and re-save it to include it next time.
- A chart with no saved query context (charts only store one once they've been
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
`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
per-sheet maximum.
@@ -74,6 +79,7 @@ will not register.
| `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. |
Credentials and region resolve through the standard boto3 chain (environment
variables, shared config, or instance role) unless overridden via
@@ -0,0 +1,92 @@
---
title: Number Formatting
sidebar_position: 11
description: Reference for the built-in D3-based number format presets available on chart metrics and axes
keywords: [number format, d3 format, formatting, duration, memory, length, distance]
---
{/*
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.
*/}
# Number Formatting
Most chart types expose a **Number format** control (labeled **D3 Format**, **Y Axis Format**, or similar depending on the chart) wherever a metric or numeric axis can be formatted. This is available from the **Customize** tab, or from the metric's popover editor, depending on the chart type. Selecting one of the built-in presets below applies that formatting to the values Superset renders in the chart.
When an axis format control's chart has **Comparison display** set to **Percentage** (e.g. period-over-period comparisons), that control's choices are narrowed to percentage-only presets, hiding `SMART_NUMBER`, `~g`, and the duration/memory/length presets.
You can also type a custom [D3 format string](https://github.com/d3/d3-format) directly into the control if none of the presets fit your needs.
## Built-in presets
### General
| Key | Description |
| --- | --- |
| `SMART_NUMBER` | Adaptive formatting — automatically picks a reasonable precision based on the value |
| `~g` | Original value, using D3's general-format specifier (trims trailing zeros; may switch to exponential notation for very large or small values) |
### D3 format strings
These are raw [D3 format specifiers](https://github.com/d3/d3-format#locale_format). The dropdown shows a live preview of each one against a sample value.
| Format spec | What it does |
| --- | --- |
| `,d` | Integer, comma-grouped thousands |
| `.1s` | SI-prefix notation, 1 significant digit |
| `.3s` | SI-prefix notation, 3 significant digits |
| `,.1%` | Percentage, 1 decimal place, comma-grouped |
| `.2%` | Percentage, 2 decimal places |
| `.3%` | Percentage, 3 decimal places |
| `.4r` | Rounded to 4 significant digits |
| `,.1f` | Fixed-point, 1 decimal place, comma-grouped |
| `,.2f` | Fixed-point, 2 decimal places, comma-grouped |
| `,.3f` | Fixed-point, 3 decimal places, comma-grouped |
| `+,` | Comma-grouped, always shows the sign |
| `$,.2f` | Currency, 2 decimal places, comma-grouped |
### Duration
| Key | Description |
| --- | --- |
| `DURATION` | Duration in ms (`66000` => `1m 6s`) |
| `DURATION_SUB` | Duration in ms, with sub-second precision (`1.40008` => `1ms 400µs 80ns`) |
| `DURATION_COL` | Duration in ms, colon-separated (`10500` => `0:00:10.5`) |
### Memory
| Key | Description |
| --- | --- |
| `MEMORY_DECIMAL` | Memory in bytes, decimal (`1024B` => `1.024kB`) |
| `MEMORY_BINARY` | Memory in bytes, binary (`1024B` => `1KiB`) |
| `MEMORY_TRANSFER_RATE_DECIMAL` | Memory transfer rate in bytes, decimal (`1024B` => `1.024kB/s`) |
| `MEMORY_TRANSFER_RATE_BINARY` | Memory transfer rate in bytes, binary (`1024B` => `1KiB/s`) |
### Distance / length
| Key | Description |
| --- | --- |
| `LENGTH` | Length in meters, converted to kilometers (`12345m` => `12.35km`) |
| `LENGTH_CM_KM` | Length in centimeters, converted to kilometers (`12345678cm` => `123.46km`) |
| `LENGTH_CM_M` | Length in centimeters, converted to meters (`12345cm` => `123.45m`) |
Use these when a metric's underlying values are stored in meters or centimeters but are easier to read at a coarser unit — for example, distances traveled, cable/pipe lengths, or elevation changes.
## Currency
Some chart types also expose currency-specific formatting, including a dynamic mode that reads the currency from a column value. See [Dynamic Currency Formatting](./creating-your-first-dashboard#dynamic-currency-formatting) for details.
+11 -11
View File
@@ -58,15 +58,15 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.5",
"@storybook/addon-docs": "^10.5.6",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.47",
"antd": "^6.5.2",
"baseline-browser-mapping": "^2.11.8",
"antd": "^6.5.3",
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001806",
"docusaurus-plugin-openapi-docs": "^5.1.3",
"docusaurus-theme-openapi-docs": "^5.1.3",
"js-yaml": "^5.2.2",
"js-yaml": "^5.2.3",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
@@ -77,8 +77,8 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.5",
"swagger-ui-react": "^5.32.11",
"storybook": "^10.5.6",
"swagger-ui-react": "^5.32.12",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"unist-util-visit": "^5.1.0"
@@ -89,14 +89,14 @@
"@eslint/js": "^9.39.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"@typescript-eslint/eslint-plugin": "^8.66.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.8.0",
"oxfmt": "^0.61.0",
"globals": "^17.9.0",
"oxfmt": "^0.62.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.65.0",
"typescript-eslint": "^8.66.0",
"webpack": "^5.109.2"
},
"browserslist": {
-6
View File
@@ -221,12 +221,6 @@
"lifecycle": "testing",
"description": "When impersonating a user, use the email prefix instead of username"
},
{
"name": "PLAYWRIGHT_REPORTS_AND_THUMBNAILS",
"default": false,
"lifecycle": "testing",
"description": "Replace Selenium with Playwright for reports and thumbnails. Supports deck.gl visualizations. Requires playwright pip package."
},
{
"name": "RLS_IN_SQLLAB",
"default": false,
+596 -531
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.22.4 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.22.5 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27
+1 -1
View File
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.22.4](https://img.shields.io/badge/Version-0.22.4-informational?style=flat-square)
![Version: 0.22.5](https://img.shields.io/badge/Version-0.22.5-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
+1
View File
@@ -328,6 +328,7 @@ class CeleryConfig:
"superset.tasks.scheduler",
"superset.tasks.thumbnails",
"superset.tasks.cache",
"superset.tasks.slack",
)
broker_connection_retry_on_startup = True
worker_prefetch_multiplier = 10
+5 -5
View File
@@ -50,7 +50,7 @@ dependencies = [
"flask-cors>=6.0.5, <7.0",
"croniter>=6.2.4",
"cron-descriptor",
"cryptography>=49.0.0, <50.0.0",
"cryptography>=50.0.0, <51.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.2, <6.0.0",
@@ -108,9 +108,9 @@ dependencies = [
"pyarrow>=24.0.0, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyyaml>=6.0.3, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=8.1.0, <9.0",
"redis>=5.0.0, <9.0",
"rison>=2.0.1, <3.0",
"selenium>=4.46.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.5, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=4.1.1",
@@ -118,7 +118,7 @@ dependencies = [
"sqlalchemy>=1.4.43, <2", # 1.4.43 adds the python-oracledb (oracle+oracledb) dialect
"sqlalchemy-continuum>=1.6.0, <2.0.0",
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=30.14.0, <31",
"sqlglot>=30.16.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
# newer pandas needs 0.9+
"tabulate>=0.10.0, <1.0",
"typing-extensions>=4.16.0, <5",
@@ -309,7 +309,7 @@ combine_as_imports = true
include_trailing_comma = true
line_length = 88
known_first_party = "superset, apache-superset-core, apache-superset-extensions-cli"
known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, marshmallow-union, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, rison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, selenium, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, syntaqlite, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml"
known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, marshmallow-union, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, rison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, syntaqlite, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml"
multi_line_output = 3
order_by_type = false
+1 -1
View File
@@ -26,7 +26,7 @@ filelock>=3.20.3,<4.0.0
brotli>=1.2.0,<2.0.0
numexpr>=2.9.0
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
cryptography>=49.0.0,<50.0.0
cryptography>=50.0.0,<51.0.0
# Security: Snyk - XSS vulnerability in Mako templates
mako>=1.3.11,<2.0.0
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
+4 -35
View File
@@ -20,10 +20,8 @@ attrs==25.3.0
# via
# cattrs
# jsonschema
# outcome
# referencing
# requests-cache
# trio
babel==2.17.0
# via flask-babel
backoff==2.2.1
@@ -53,9 +51,7 @@ cattrs==25.1.1
celery==5.6.3
# via apache-superset (pyproject.toml)
certifi==2026.5.20
# via
# requests
# selenium
# via requests
cffi==2.0.0
# via
# cryptography
@@ -88,7 +84,7 @@ cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.2.4
# via apache-superset (pyproject.toml)
cryptography==49.0.0
cryptography==50.0.0
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
@@ -174,8 +170,6 @@ greenlet==3.5.4
# sqlalchemy
gunicorn==26.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
# via wsproto
hashids==1.3.1
# via apache-superset (pyproject.toml)
holidays==0.102
@@ -186,7 +180,6 @@ idna==3.15
# via
# email-validator
# requests
# trio
# url-normalize
isodate==0.7.2
# via
@@ -265,10 +258,6 @@ openpyxl==3.1.5
# via pandas
ordered-set==4.1.0
# via flask-limiter
outcome==1.3.0.post0
# via
# trio
# trio-websocket
packaging==25.0
# via
# apache-superset (pyproject.toml)
@@ -326,14 +315,12 @@ pyjwt==2.13.0
# flask-jwt-extended
pynacl==1.6.2
# via paramiko
pyopenssl==26.3.0
pyopenssl==26.4.0
# via
# -r requirements/base.in
# shillelagh
pyparsing==3.3.2
# via apache-superset (pyproject.toml)
pysocks==1.7.1
# via urllib3
python-calamine==0.8.2
# via pandas
python-dateutil==2.9.0.post0
@@ -380,8 +367,6 @@ rpds-py==0.25.0
# via
# jsonschema
# referencing
selenium==4.46.0
# via apache-superset (pyproject.toml)
setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.5
@@ -396,10 +381,6 @@ six==1.17.0
# wtforms-json
slack-sdk==3.43.0
# via apache-superset (pyproject.toml)
sniffio==1.3.1
# via trio
sortedcontainers==2.4.0
# via trio
sqlalchemy==1.4.54
# via
# apache-superset (pyproject.toml)
@@ -418,7 +399,7 @@ sqlalchemy-utils==0.42.1
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==30.15.0
sqlglot==30.16.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
@@ -426,12 +407,6 @@ sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.10.0
# via apache-superset (pyproject.toml)
trio==0.33.0
# via
# selenium
# trio-websocket
trio-websocket==0.12.2
# via selenium
typing-extensions==4.16.0
# via
# apache-superset (pyproject.toml)
@@ -443,7 +418,6 @@ typing-extensions==4.16.0
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.2
@@ -461,7 +435,6 @@ urllib3==2.7.0
# -r requirements/base.in
# requests
# requests-cache
# selenium
vine==5.1.0
# via
# amqp
@@ -471,8 +444,6 @@ watchdog==6.0.0
# via apache-superset (pyproject.toml)
wcwidth==0.2.13
# via prompt-toolkit
websocket-client==1.8.0
# via selenium
werkzeug==3.1.6
# via
# -r requirements/base.in
@@ -483,8 +454,6 @@ werkzeug==3.1.6
# flask-login
wrapt==1.17.2
# via deprecated
wsproto==1.2.0
# via trio-websocket
wtforms==3.2.2
# via
# apache-superset (pyproject.toml)
+4 -49
View File
@@ -48,10 +48,8 @@ attrs==25.3.0
# cattrs
# cyclopts
# jsonschema
# outcome
# referencing
# requests-cache
# trio
authlib==1.6.12
# via fastmcp-slim
babel==2.17.0
@@ -122,7 +120,6 @@ certifi==2026.5.20
# httpcore
# httpx
# requests
# selenium
cffi==2.0.0
# via
# -c requirements/base-constraint.txt
@@ -182,7 +179,7 @@ croniter==6.2.4
# via
# -c requirements/base-constraint.txt
# apache-superset
cryptography==49.0.0
cryptography==50.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -400,10 +397,8 @@ gunicorn==26.0.0
# apache-superset
h11==0.16.0
# via
# -c requirements/base-constraint.txt
# httpcore
# uvicorn
# wsproto
hashids==1.3.1
# via
# -c requirements/base-constraint.txt
@@ -434,7 +429,6 @@ idna==3.15
# email-validator
# httpx
# requests
# trio
# url-normalize
importlib-metadata==8.7.0
# via
@@ -619,11 +613,6 @@ ordered-set==4.1.0
# flask-limiter
orjson==3.11.9
# via trino
outcome==1.3.0.post0
# via
# -c requirements/base-constraint.txt
# trio
# trio-websocket
packaging==25.0
# via
# -c requirements/base-constraint.txt
@@ -791,7 +780,7 @@ pynacl==1.6.2
# via
# -c requirements/base-constraint.txt
# paramiko
pyopenssl==26.3.0
pyopenssl==26.4.0
# via
# -c requirements/base-constraint.txt
# google-auth
@@ -803,10 +792,6 @@ pyparsing==3.3.2
# matplotlib
pyperclip==1.10.0
# via fastmcp-slim
pysocks==1.7.1
# via
# -c requirements/base-constraint.txt
# urllib3
pytest==7.4.4
# via
# apache-superset
@@ -934,10 +919,6 @@ s3transfer==0.16.0
# via boto3
secretstorage==3.5.0
# via keyring
selenium==4.46.0
# via
# -c requirements/base-constraint.txt
# apache-superset
semver==3.0.4
# via apache-superset-extensions-cli
setuptools==80.9.0
@@ -968,14 +949,7 @@ slack-sdk==3.43.0
# -c requirements/base-constraint.txt
# apache-superset
sniffio==1.3.1
# via
# -c requirements/base-constraint.txt
# anyio
# trio
sortedcontainers==2.4.0
# via
# -c requirements/base-constraint.txt
# trio
# via anyio
sqlalchemy==1.4.54
# via
# -c requirements/base-constraint.txt
@@ -1002,7 +976,7 @@ sqlalchemy-utils==0.42.1
# apache-superset
# apache-superset-core
# flask-appbuilder
sqlglot==30.15.0
sqlglot==30.16.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -1039,15 +1013,6 @@ tqdm==4.67.1
# prophet
trino==0.338.0
# via apache-superset
trio==0.33.0
# via
# -c requirements/base-constraint.txt
# selenium
# trio-websocket
trio-websocket==0.12.2
# via
# -c requirements/base-constraint.txt
# selenium
typing-extensions==4.16.0
# via
# -c requirements/base-constraint.txt
@@ -1067,7 +1032,6 @@ typing-extensions==4.16.0
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# starlette
# typing-inspection
@@ -1100,7 +1064,6 @@ urllib3==2.7.0
# docker
# requests
# requests-cache
# selenium
uvicorn==0.37.0
# via
# fastmcp-slim
@@ -1124,10 +1087,6 @@ wcwidth==0.2.13
# via
# -c requirements/base-constraint.txt
# prompt-toolkit
websocket-client==1.8.0
# via
# -c requirements/base-constraint.txt
# selenium
websockets==15.0.1
# via fastmcp-slim
werkzeug==3.1.6
@@ -1142,10 +1101,6 @@ wrapt==1.17.2
# via
# -c requirements/base-constraint.txt
# deprecated
wsproto==1.2.0
# via
# -c requirements/base-constraint.txt
# trio-websocket
wtforms==3.2.2
# via
# -c requirements/base-constraint.txt
+1
View File
@@ -42,6 +42,7 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
PATTERNS = {
"python": [
r"^\.github/workflows/.*python",
r"^\.github/workflows/scheduled-docker-image-refresh\.yml$",
r"^docker-compose-image-tag\.yml$",
r"^tests/",
r"^superset/",
+1 -1
View File
@@ -77,7 +77,7 @@ module.exports = {
// @ant-design/colors and @ant-design/fast-color are allowed through because
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
// from its CJS output, so babel-jest must transform those files.
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
],
preset: 'ts-jest',
transform: {
+13
View File
@@ -276,6 +276,19 @@
"test*WithInitialValues"
]
}
],
// === ESLint rules ===
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "lodash",
"message": "Please use tree-shakeable lodash-es instead"
}
]
}
]
},
"overrides": [
+714 -333
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -158,7 +158,7 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.0.2",
"ag-grid-react": "36.0.2",
"antd": "^6.5.2",
"antd": "^6.5.3",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
@@ -186,12 +186,12 @@
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.27.0",
"markdown-to-jsx": "^9.10.1",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
"mustache": "^4.2.0",
"nanoid": "^6.0.0",
"nanoid": "^6.0.1",
"ol": "^10.10.0",
"query-string": "9.4.1",
"re-resizable": "^6.11.2",
@@ -250,16 +250,16 @@
"@babel/register": "^7.29.7",
"@babel/runtime": "^7.29.7",
"@babel/runtime-corejs3": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/types": "^7.29.8",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.18",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.5",
"@storybook/addon-links": "10.5.5",
"@storybook/react-webpack5": "10.5.5",
"@storybook/addon-docs": "10.5.6",
"@storybook/addon-links": "10.5.6",
"@storybook/react-webpack5": "10.5.6",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
@@ -289,13 +289,13 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/eslint-plugin": "^8.66.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"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.8",
"baseline-browser-mapping": "^2.11.12",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"copy-webpack-plugin": "^14.0.0",
@@ -307,12 +307,12 @@
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest-dom": "^5.7.0",
"eslint-plugin-jest-dom": "^5.10.1",
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.5",
"eslint-plugin-storybook": "10.5.6",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -331,8 +331,8 @@
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"open-cli": "^9.0.0",
"oxfmt": "^0.61.0",
"oxlint": "^1.76.0",
"oxfmt": "^0.62.0",
"oxlint": "^1.77.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"process": "^0.11.10",
@@ -343,13 +343,13 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.5",
"storybook": "10.5.6",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
"ts-jest": "^29.4.12",
"tscw-config": "^1.1.2",
"tsx": "^4.23.1",
"tsx": "^4.23.5",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
@@ -106,7 +106,7 @@
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"@testing-library/user-event": "*",
"@types/lodash": "^4.17.24",
"@types/lodash": "^4.17.25",
"@types/react": "*",
"@types/react-loadable": "*",
"@types/tinycolor2": "*",
@@ -160,6 +160,43 @@ test('Theme.setConfig correctly applies algorithm changes', () => {
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
test('Theme.setConfig with baseTheme merges the config over the base theme tokens', () => {
const baseTheme: AnyThemeConfig = {
token: { colorPrimary: '#111111', colorError: '#ff0000' },
};
const theme = Theme.fromConfig();
theme.setConfig({ token: { colorPrimary: '#0000ff' } }, baseTheme);
// Config wins for colorPrimary; the base theme fills the untouched colorError.
expect(theme.theme.colorPrimary).toBe('#0000ff');
expect(theme.theme.colorError).toBe('#ff0000');
});
test('Theme.setConfig with baseTheme keeps the base theme ECharts overrides', () => {
const baseTheme = {
token: { colorPrimary: '#111111' },
echartsOptionsOverrides: { backgroundColor: '#123456' },
echartsOptionsOverridesByChartType: {
pie: { itemStyle: { borderWidth: 2 } },
},
} as AnyThemeConfig & {
echartsOptionsOverrides: Record<string, unknown>;
echartsOptionsOverridesByChartType: Record<string, unknown>;
};
const theme = Theme.fromConfig();
// In-place update whose config sets no ECharts overrides: the base theme's
// overrides must survive, the same way its tokens do.
theme.setConfig({ token: { colorPrimary: '#0000ff' } }, baseTheme);
expect(theme.theme.echartsOptionsOverrides).toEqual({
backgroundColor: '#123456',
});
expect(theme.theme.echartsOptionsOverridesByChartType).toEqual({
pie: { itemStyle: { borderWidth: 2 } },
});
});
test('Theme.toggleDarkMode switches to dark algorithm when toggling dark mode on', () => {
const theme = Theme.fromConfig();
@@ -64,10 +64,12 @@ export class Theme {
* @param config - The theme configuration
* @param baseTheme - Optional base theme to apply under the config
*/
static fromConfig(
// Merge a config over an optional base theme (arrays replace rather than
// deep-merge; a colorPrimary override without colorLink aligns colorLink).
private static mergeConfig(
config?: AnyThemeConfig,
baseTheme?: AnyThemeConfig,
): Theme {
): AnyThemeConfig | undefined {
let mergedConfig: AnyThemeConfig | undefined = config;
if (baseTheme && config) {
@@ -76,9 +78,9 @@ export class Theme {
);
// In Ant Design v5, colorLink derives from colorInfo, not colorPrimary.
// Currently we expectlinks to follow the brand/primary color. When the user
// overrides colorPrimary without explicitly setting colorLink, update the
// merged colorLink so links match the new primary palette.
// We expect links to follow the brand/primary color, so when a config
// overrides colorPrimary without setting colorLink, align the merged
// colorLink with the new primary palette.
if (config.token?.colorPrimary && !config.token?.colorLink) {
const mToken = mergedConfig?.token;
if (mToken) {
@@ -89,7 +91,14 @@ export class Theme {
mergedConfig = baseTheme;
}
return new Theme({ config: mergedConfig });
return mergedConfig;
}
static fromConfig(
config?: AnyThemeConfig,
baseTheme?: AnyThemeConfig,
): Theme {
return new Theme({ config: Theme.mergeConfig(config, baseTheme) });
}
private static getFilteredAntdTheme(
@@ -110,12 +119,14 @@ export class Theme {
}
/**
* Update the theme using any theme configuration
* Automatically handles both AntdThemeConfig and SerializableThemeConfig
* Dark mode should be specified via the algorithm property in the config
* Update the theme using any theme configuration, optionally merged over a
* base theme. Automatically handles both AntdThemeConfig and
* SerializableThemeConfig. Dark mode should be specified via the algorithm
* property in the config.
*/
setConfig(config: AnyThemeConfig): void {
const antdConfig = normalizeThemeConfig(config);
setConfig(config: AnyThemeConfig, baseTheme?: AnyThemeConfig): void {
const mergedConfig = Theme.mergeConfig(config, baseTheme) ?? config;
const antdConfig = normalizeThemeConfig(mergedConfig);
if (antdConfig.token?.colorPrimary && !antdConfig.token?.colorLink) {
antdConfig.token.colorLink = antdConfig.token.colorPrimary;
@@ -124,11 +135,11 @@ export class Theme {
// First phase: Let Ant Design compute the tokens
const tokens = Theme.getFilteredAntdTheme(antdConfig);
// Extract Superset-specific properties from top-level config.
// These are custom properties that aren't part of Ant Design's token system
// but need to be passed through to the SupersetTheme for ECharts customization.
// Extract Superset-specific properties from the merged config (not the raw
// config) so a base theme's ECharts overrides survive in-place updates, the
// same way the Ant Design tokens above are taken from the merged config.
const { echartsOptionsOverrides, echartsOptionsOverridesByChartType } =
config as AnyThemeConfig & {
mergedConfig as AnyThemeConfig & {
echartsOptionsOverrides?: any;
echartsOptionsOverridesByChartType?: Record<string, any>;
};
@@ -68,6 +68,11 @@ export const D3_FORMAT_OPTIONS: [string, string][] = [
'MEMORY_TRANSFER_RATE_BINARY',
t('Memory transfer rate in bytes - binary (1024B => 1KiB/s)'),
],
['NETWORK_THROUGHPUT', t('Network throughput in bits/s (1000000 => 1Mbps)')],
[
'NETWORK_THROUGHPUT_FROM_BYTES',
t('Network throughput in bytes/s as bits (1000000 => 8Mbps)'),
],
['LENGTH', t('Length in m (12345m => 12.35km)')],
['LENGTH_CM_KM', t('Length in cm (12345678cm => 123.46km)')],
['LENGTH_CM_M', t('Length in cm (12345cm => 123.45m)')],
@@ -102,7 +102,7 @@
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/lodash": "^4.17.25",
"@types/node": "^26.1.2",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -18,7 +18,7 @@
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { isEqual } from 'lodash';
import { isEqual } from 'lodash-es';
import { ParentSize } from '@visx/responsive';
import { t } from '@apache-superset/core/translation';
import {
@@ -49,9 +49,14 @@ export type ReactifyProps = {
className?: string;
};
export interface ReactifyUnmountContext<Props extends object = object> {
container?: HTMLDivElement;
props: Readonly<Props & ReactifyProps>;
}
// TODO: add more React lifecycle callbacks as needed
export type LifeCycleCallbacks = {
componentWillUnmount?: () => void;
export type LifeCycleCallbacks<Props extends object = object> = {
componentWillUnmount?: (this: ReactifyUnmountContext<Props>) => void;
};
export interface RenderFuncType<Props> {
@@ -76,7 +81,7 @@ export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
// `ReactifiedComponent<Props>` explicitly.
export default function reactify<Props extends object>(
renderFn: RenderFuncType<Props>,
callbacks?: LifeCycleCallbacks,
callbacks?: LifeCycleCallbacks<Props>,
): ComponentType<Props & ReactifyProps> {
const ReactifiedComponent = forwardRef<
ReactifiedComponentRef,
@@ -89,8 +94,10 @@ export default function reactify<Props extends object>(
// assignment only happens for committed renders (safe under Concurrent
// Mode) and is in place before the passive unmount effect reads it.
const propsRef = useRef(props);
const committedContainerRef = useRef<HTMLDivElement>();
useLayoutEffect(() => {
propsRef.current = props;
committedContainerRef.current = containerRef.current ?? undefined;
});
// Expose container via ref for external access
@@ -104,6 +111,22 @@ export default function reactify<Props extends object>(
[],
);
// Cleanup on unmount
useEffect(
() => () => {
if (callbacks?.componentWillUnmount) {
// Preserve the legacy `this.props` access pattern and snapshot the
// last committed container because React clears refs before passive
// effect cleanup runs on unmount.
callbacks.componentWillUnmount.call({
container: committedContainerRef.current,
props: propsRef.current,
});
}
},
[],
);
// Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
useEffect(() => {
if (containerRef.current) {
@@ -118,24 +141,6 @@ export default function reactify<Props extends object>(
}
});
// Cleanup on unmount
useEffect(
() => () => {
if (callbacks?.componentWillUnmount) {
// Preserve legacy behavior where `this` was a component instance
// exposing `props`. The class version cleared `this.container`
// before invoking componentWillUnmount, so mirror that here to
// prevent callbacks from touching a DOM node that's being torn
// down.
callbacks.componentWillUnmount.call({
container: undefined,
props: propsRef.current,
});
}
},
[],
);
const { id, className } = props;
return <div ref={containerRef} id={id} className={className} />;
@@ -17,6 +17,7 @@
* under the License.
*/
import { useState } from 'react';
import { Button } from '../Button';
import { Modal } from './Modal';
import type { ModalProps, ModalFuncProps } from './types';
@@ -179,3 +180,74 @@ ModalFunctions.args = {
maskClosable: true,
mask: true,
};
/**
* Two top-level Modals that are React siblings, not nested inside one
* another (e.g. a "View query" modal and a confirmation dialog it can
* trigger, like `UnsavedChangesModal`). Ant Design only assigns an
* automatically-incremented z-index when a Modal is nested inside another
* *currently open* Modal's React tree, so two siblings always fall back to
* the same static z-index and are tie-broken by DOM order: whichever
* `.ant-modal-wrap` was inserted later paints on top.
*
* With `destroyOnHidden={false}` (Ant Design's default), a Modal's wrap
* node is created once, lazily, on first open, and is never removed or
* recreated afterward. So the modal that happens to have been opened
* *first ever*, not most recently, keeps winning the DOM-order tiebreak
* even after being closed and reopened. Toggle "Reproduce stale DOM order"
* off to see the fix: with `destroyOnHidden`, every open recreates the wrap
* node at the end of the document, so DOM order (and stacking) always
* matches true open-recency and no manual z-index is ever needed.
*
* To see the bug: click "Open A", close it, then "Open B", then "Open A"
* again -- with the toggle on, A renders behind B despite being the modal
* that was opened most recently.
*/
export const SiblingModalStacking = ({
reproduceStaleDomOrder,
}: {
reproduceStaleDomOrder: boolean;
}) => {
const [showA, setShowA] = useState(false);
const [showB, setShowB] = useState(false);
return (
<div>
<Button onClick={() => setShowA(true)} buttonStyle="secondary">
Open A
</Button>
<Button onClick={() => setShowB(true)} buttonStyle="secondary">
Open B
</Button>
<Modal
name="modal-a"
title="Modal A"
show={showA}
onHide={() => setShowA(false)}
destroyOnHidden={!reproduceStaleDomOrder}
>
Modal A content
</Modal>
<Modal
name="modal-b"
title="Modal B"
show={showB}
onHide={() => setShowB(false)}
destroyOnHidden={!reproduceStaleDomOrder}
>
Modal B content
</Modal>
</div>
);
};
SiblingModalStacking.args = {
reproduceStaleDomOrder: true,
};
SiblingModalStacking.argTypes = {
reproduceStaleDomOrder: {
control: 'boolean',
description:
'On: Ant Design default behavior, a modal opened once keeps its DOM position forever (the bug from #42510). Off: destroyOnHidden, DOM order always matches true open-recency (the fix).',
},
};
@@ -16,7 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { useState } from 'react';
import {
render,
screen,
userEvent,
waitFor,
within,
} from '@superset-ui/core/spec';
import { Modal, RawAntdTooltip } from '@superset-ui/core/components';
import { UnsavedChangesModal } from '.';
test('should render nothing if showModal is false', () => {
@@ -94,3 +102,173 @@ test('should only call handleSave when clicking the Save button', async () => {
expect(mockOnHide).not.toHaveBeenCalled();
expect(mockOnConfirmNavigation).not.toHaveBeenCalled();
});
// Regression coverage for the underlying bug (#42510): this modal could
// render BEHIND another already-open modal (e.g. a draggable "View query"
// modal). Two plain top-level Modal siblings (neither nested inside the
// other's React tree) fall back to the same static z-index, tie-broken by
// DOM order: whichever `.ant-modal-wrap` comes later in the document paints
// on top -- `destroyOnHidden` is what makes every open recreate this
// modal's wrap fresh at the end of the document, so it wins that tie. But
// the real #42510 repro isn't actually a tie: "View query" renders as a
// dropdown menu item's label, and Ant Design's Menu.Item wraps every item's
// content in a Tooltip (even one that never opens), which hands its
// children a real elevated z-index via React context. That's why this
// modal also sets an explicit `zIndex` -- comfortably above what that
// inherited context can produce -- rather than relying on DOM order alone.
function dialogWrap(titleText: string) {
const dialogs = screen.queryAllByRole('dialog');
// rc-util's `useId` hook always returns the same mocked id ("test-id") in
// test environments, so with two dialogs open at once their
// `aria-labelledby` ids collide and `getByRole('dialog', { name })` can't
// tell them apart. Find each by its title text instead.
const dialog = dialogs.find(d => within(d).queryByText(titleText));
return dialog?.closest<HTMLElement>('.ant-modal-wrap') ?? null;
}
test('renders above an already-open modal that also has no elevated z-index', async () => {
render(
<>
<Modal show title="Other open modal" onHide={() => {}}>
<div>Other modal content</div>
</Modal>
<UnsavedChangesModal
showModal
onHide={() => {}}
handleSave={() => {}}
onConfirmNavigation={() => {}}
/>
</>,
);
const otherWrap = await waitFor(() => {
const wrap = dialogWrap('Other open modal');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
const unsavedChangesWrap = await waitFor(() => {
const wrap = dialogWrap('Unsaved Changes');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
// eslint-disable-next-line no-bitwise
expect(
otherWrap.compareDocumentPosition(unsavedChangesWrap) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
// This is the actual #42510 repro, not just a tied-sibling stand-in: "View
// query" is rendered as a dropdown menu item's label, so Ant Design's
// Menu.Item silently wraps it in a Tooltip (title/open both stay falsy, it
// never visibly opens) purely for its own ellipsis-title behavior. That
// Tooltip still supplies a real, elevated z-index to its children via
// context, so the modal nested inside it doesn't tie with a plain top-level
// modal the way the previous test's "Other open modal" does -- DOM order
// can't be the tie-breaker for two z-indexes that were never equal.
test('renders above a modal nested in a menu item Tooltip wrapper, which gets a real elevated z-index', async () => {
render(
<>
<RawAntdTooltip title={null} open={false}>
<Modal show title="View query" onHide={() => {}}>
<div>query body</div>
</Modal>
</RawAntdTooltip>
<UnsavedChangesModal
showModal
onHide={() => {}}
handleSave={() => {}}
onConfirmNavigation={() => {}}
/>
</>,
);
const viewQueryWrap = await waitFor(() => {
const wrap = dialogWrap('View query');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
const unsavedChangesWrap = await waitFor(() => {
const wrap = dialogWrap('Unsaved Changes');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
// The Tooltip wrapper does give "View query" a real inline z-index above
// the base -- confirming this test actually exercises an elevated,
// non-tied sibling rather than accidentally falling back to the tied
// case the previous test already covers.
expect(Number(viewQueryWrap.style.zIndex)).toBeGreaterThan(0);
expect(Number(unsavedChangesWrap.style.zIndex)).toBeGreaterThan(
Number(viewQueryWrap.style.zIndex),
);
});
test('still renders on top after being opened, closed, and reopened once the other modal is already open', async () => {
function Harness() {
const [showOther, setShowOther] = useState(false);
const [showUnsaved, setShowUnsaved] = useState(false);
return (
<>
<button type="button" onClick={() => setShowOther(true)}>
open other
</button>
<button type="button" onClick={() => setShowUnsaved(true)}>
open unsaved
</button>
<Modal
show={showOther}
title="Other open modal"
onHide={() => setShowOther(false)}
>
<div>Other modal content</div>
</Modal>
<UnsavedChangesModal
showModal={showUnsaved}
onHide={() => setShowUnsaved(false)}
handleSave={() => {}}
// Mirrors real callers: confirming navigation is what dismisses
// this modal, not `onHide` directly (see the Discard-button test
// above -- clicking Discard never calls `onHide` on its own).
onConfirmNavigation={() => setShowUnsaved(false)}
/>
</>
);
}
render(<Harness />);
// Open this modal once -- e.g. some other in-app action tripped it --
// before the modal it's supposed to interrupt has ever been opened. Its
// wrap node gets created now, first in the document.
userEvent.click(screen.getByText('open unsaved'));
await waitFor(() => expect(dialogWrap('Unsaved Changes')).not.toBeNull());
userEvent.click(await screen.findByRole('button', { name: /discard/i }));
await waitFor(() => expect(dialogWrap('Unsaved Changes')).toBeNull());
// Now open the modal it's meant to interrupt for the first time.
userEvent.click(screen.getByText('open other'));
const otherWrap = await waitFor(() => {
const wrap = dialogWrap('Other open modal');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
// Reopen this modal -- the real scenario the bug report describes. If its
// wrap node were still the one created on the first open above, it would
// be stuck earlier in the document than `otherWrap` and render behind it
// again.
userEvent.click(screen.getByText('open unsaved'));
const unsavedChangesWrap = await waitFor(() => {
const wrap = dialogWrap('Unsaved Changes');
expect(wrap).not.toBeNull();
return wrap as HTMLElement;
});
// eslint-disable-next-line no-bitwise
expect(
otherWrap.compareDocumentPosition(unsavedChangesWrap) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
@@ -17,13 +17,10 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { useTheme } from '@apache-superset/core/theme';
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
import type { FC, ReactElement } from 'react';
// Ant Design's default modal zIndex is 1000. Using a higher value ensures
// this dialog always renders above other open modals (e.g. a draggable View SQL modal).
const UNSAVED_CHANGES_MODAL_Z_INDEX = 1300;
export type UnsavedChangesModalProps = {
showModal: boolean;
onHide: () => void;
@@ -31,7 +28,6 @@ export type UnsavedChangesModalProps = {
onConfirmNavigation: () => void;
title?: string;
body?: string;
zIndex?: number;
};
export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
@@ -41,32 +37,61 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
onConfirmNavigation,
title = 'Unsaved Changes',
body = "If you don't save, changes will be lost.",
zIndex = UNSAVED_CHANGES_MODAL_Z_INDEX,
}: UnsavedChangesModalProps): ReactElement => (
<Modal
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
zIndex={zIndex}
title={
<>
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
{title}
</>
}
footer={
<>
<Button buttonStyle="secondary" onClick={onConfirmNavigation}>
{t('Discard')}
</Button>
<Button buttonStyle="primary" onClick={handleSave}>
{t('Save')}
</Button>
</>
}
>
<Typography.Text>{body}</Typography.Text>
</Modal>
);
}: UnsavedChangesModalProps): ReactElement => {
const theme = useTheme();
return (
<Modal
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
// This modal always interrupts something already on screen (a
// draggable "View query" modal, an in-progress form, etc). Ant
// Design only assigns a higher z-index automatically when a Modal is
// nested inside another *currently open Modal's* React tree. This
// one is always a top-level sibling of whatever it interrupts, so on
// its own it would fall back to the same static base z-index -- BUT
// the modal it's interrupting isn't always a plain top-level sibling
// itself: "View query" is rendered as a dropdown menu item's label,
// and Ant Design's Menu.Item silently wraps every item's content in
// a Tooltip (even when that tooltip never opens), which supplies a
// real ZIndexContext to its children. That gives the nested "View
// query" Modal a genuinely higher, non-tied z-index (theme's popup
// base plus ~200) than this modal's plain base value, so DOM order
// alone (destroyOnHidden below) can't win the tie -- there isn't
// one. An explicit zIndex, comfortably above any such context-fed
// value, guarantees this modal isn't shadowed by a sibling that
// happens to inherit an elevated stacking context.
zIndex={theme.zIndexPopupBase + 1000}
// Without destroyOnHidden, a Modal's portal node is created once
// (lazily, on first open) and then left in place forever, so if this
// dialog is ever opened once before whatever it's interrupting is
// opened, a later reopen would go right back to that stale,
// now-too-early DOM position. destroyOnHidden tears the portal down
// on every close so every open recreates it fresh at the end of the
// DOM, keeping DOM order (the tie-breaker for any modals that
// genuinely do share this one's base z-index) tracking true
// open-recency.
destroyOnHidden
title={
<>
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
{title}
</>
}
footer={
<>
<Button buttonStyle="secondary" onClick={onConfirmNavigation}>
{t('Discard')}
</Button>
<Button buttonStyle="primary" onClick={handleSave}>
{t('Save')}
</Button>
</>
}
>
<Typography.Text>{body}</Typography.Text>
</Modal>
);
};
@@ -0,0 +1,86 @@
/*
* 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 NumberFormatter from '../NumberFormatter';
import { NumberFormatFunction } from '../types';
const BITS_PER_BYTE = 8;
const BASE = 1000;
const SUFFIXES = [
'bps',
'kbps',
'Mbps',
'Gbps',
'Tbps',
'Pbps',
'Ebps',
'Zbps',
'Ybps',
'Rbps',
'Qbps',
];
function formatThroughput(
decimals: number,
fromBytes: boolean,
): NumberFormatFunction {
return value => {
if (value === 0) {
return `0${SUFFIXES[0]}`;
}
const sign = value > 0 ? '' : '-';
const magnitude = Math.abs(value);
const bits = fromBytes ? magnitude * BITS_PER_BYTE : magnitude;
let i = Math.max(
0,
Math.min(
SUFFIXES.length - 1,
Math.floor(Math.log(bits) / Math.log(BASE)),
),
);
let scaled = parseFloat((bits / Math.pow(BASE, i)).toFixed(decimals));
if (scaled >= BASE && i < SUFFIXES.length - 1) {
i += 1;
scaled = parseFloat((bits / Math.pow(BASE, i)).toFixed(decimals));
}
return `${sign}${scaled}${SUFFIXES[i]}`;
};
}
export default function createThroughputFormatter(
config: {
description?: string;
id?: string;
label?: string;
decimals?: number;
fromBytes?: boolean;
} = {},
) {
const { description, id, label, decimals = 2, fromBytes = false } = config;
return new NumberFormatter({
description,
formatFunc: formatThroughput(decimals, fromBytes),
id: id ?? 'throughput_format',
label: label ?? `Throughput formatter`,
});
}
@@ -36,3 +36,4 @@ export { default as createSiAtMostNDigitFormatter } from './factories/createSiAt
export { default as createSmartNumberFormatter } from './factories/createSmartNumberFormatter';
export { default as getSmallNumberFormatter } from './getSmallNumberFormatter';
export { default as createLengthFormatter } from './factories/createLengthFormatter';
export { default as createThroughputFormatter } from './factories/createThroughputFormatter';
@@ -54,7 +54,12 @@ class LRUCache<T> {
if (typeof key !== 'string') {
throw new TypeError('The LRUCache key must be string.');
}
if (this.cache.size >= this.capacity) {
if (this.cache.has(key)) {
// Overwriting an existing key must not evict anything: the entry count is
// unchanged. Deleting first also refreshes the key's recency, since
// Map#set keeps the original insertion position for existing keys.
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
// Forward-compat: TS 6.0 types IteratorResult.value as `string | undefined`
// when not explicitly checked; guard before passing to Map#delete.
const oldestKey = this.cache.keys().next().value;
@@ -45,7 +45,16 @@ describe('reactify(renderFn)', () => {
content: 'ghi',
};
const willUnmountCb = jest.fn();
let latestUnmountContext:
| {
container?: HTMLDivElement;
props?: { content?: string; id?: string };
}
| undefined;
const willUnmountCb = jest.fn(function captureUnmountContext() {
latestUnmountContext = this as typeof latestUnmountContext;
});
const TheChart = reactify(renderFn);
const TheChartWithWillUnmountHook = reactify(renderFn, {
@@ -66,12 +75,22 @@ describe('reactify(renderFn)', () => {
}
function AnotherTestComponent() {
return <TheChartWithWillUnmountHook id="another_test" />;
const [content, setContent] = useState('abc');
useEffect(() => {
const timer = setTimeout(() => {
setContent('def');
}, 10);
return () => clearTimeout(timer);
}, []);
return <TheChartWithWillUnmountHook id="another_test" content={content} />;
}
beforeEach(() => {
(renderFn as jest.Mock).mockClear();
willUnmountCb.mockClear();
latestUnmountContext = undefined;
});
test('returns a React component and re-renders on prop changes', async () => {
@@ -126,9 +145,22 @@ describe('reactify(renderFn)', () => {
expect(anotherRenderFn).toHaveBeenCalled();
unmount();
});
test('calls willUnmount hook when it is provided', () => {
test('calls willUnmount hook with the committed container and latest props', async () => {
const { unmount } = render(<AnotherTestComponent />);
await waitFor(() => {
expect(screen.getByText('def')).toBeInTheDocument();
});
const committedContainer = screen.getByText('def').parentElement;
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
expect(latestUnmountContext?.props).toMatchObject({
id: 'another_test',
content: 'def',
});
expect(latestUnmountContext?.container).toBe(committedContainer);
});
});
@@ -0,0 +1,131 @@
/*
* 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 { NumberFormatter, createThroughputFormatter } from '@superset-ui/core';
test('creates an instance of NumberFormatter', () => {
const formatter = createThroughputFormatter();
expect(formatter).toBeInstanceOf(NumberFormatter);
});
test('uses default id and label', () => {
const formatter = createThroughputFormatter();
expect(formatter.id).toBe('throughput_format');
expect(formatter.label).toBe('Throughput formatter');
});
test('accepts a custom id, label and description', () => {
const formatter = createThroughputFormatter({
id: 'custom_id',
label: 'Custom label',
description: 'Custom description',
});
expect(formatter.id).toBe('custom_id');
expect(formatter.label).toBe('Custom label');
expect(formatter.description).toBe('Custom description');
});
test('formats bits per second without converting', () => {
const formatter = createThroughputFormatter();
expect(formatter(0)).toBe('0bps');
expect(formatter(500)).toBe('500bps');
expect(formatter(1500)).toBe('1.5kbps');
expect(formatter(8888)).toBe('8.89kbps');
expect(formatter(1000000)).toBe('1Mbps');
expect(formatter(1500000)).toBe('1.5Mbps');
expect(formatter(1500000000)).toBe('1.5Gbps');
});
test('scales bits per second across the full range of suffixes', () => {
const formatter = createThroughputFormatter();
expect(formatter(Math.pow(1000, 1))).toBe('1kbps');
expect(formatter(Math.pow(1000, 2))).toBe('1Mbps');
expect(formatter(Math.pow(1000, 3))).toBe('1Gbps');
expect(formatter(Math.pow(1000, 4))).toBe('1Tbps');
expect(formatter(Math.pow(1000, 5))).toBe('1Pbps');
expect(formatter(Math.pow(1000, 6))).toBe('1Ebps');
expect(formatter(Math.pow(1000, 7))).toBe('1Zbps');
expect(formatter(Math.pow(1000, 8))).toBe('1Ybps');
expect(formatter(Math.pow(1000, 9))).toBe('1Rbps');
expect(formatter(Math.pow(1000, 10))).toBe('1Qbps');
});
test('converts bytes per second to bits per second with fromBytes', () => {
const formatter = createThroughputFormatter({ fromBytes: true });
expect(formatter(0)).toBe('0bps');
expect(formatter(1)).toBe('8bps');
expect(formatter(100)).toBe('800bps');
expect(formatter(125)).toBe('1kbps');
expect(formatter(1111)).toBe('8.89kbps');
expect(formatter(187500)).toBe('1.5Mbps');
expect(formatter(1000000)).toBe('8Mbps');
expect(formatter(125000000)).toBe('1Gbps');
});
test('scales bytes per second across the full range of suffixes', () => {
const formatter = createThroughputFormatter({ fromBytes: true });
expect(formatter(Math.pow(1000, 1) / 8)).toBe('1kbps');
expect(formatter(Math.pow(1000, 5) / 8)).toBe('1Pbps');
expect(formatter(Math.pow(1000, 10) / 8)).toBe('1Qbps');
});
test('promotes to the next suffix when rounding reaches the base', () => {
const formatter = createThroughputFormatter();
expect(formatter(999.999)).toBe('1kbps');
expect(formatter(999999)).toBe('1Mbps');
expect(formatter(999999999)).toBe('1Gbps');
expect(formatter(-999999)).toBe('-1Mbps');
const fromBytes = createThroughputFormatter({ fromBytes: true });
expect(fromBytes(124999.99)).toBe('1Mbps');
const formatter0decimals = createThroughputFormatter({ decimals: 0 });
expect(formatter0decimals(999.6)).toBe('1kbps');
});
test('clamps to the largest suffix beyond the known range', () => {
const formatter = createThroughputFormatter();
expect(formatter(Math.pow(1000, 11))).toBe('1000Qbps');
expect(formatter(Math.pow(1000, 12))).toBe('1000000Qbps');
});
test('clamps to the smallest suffix below one bit per second', () => {
const formatter = createThroughputFormatter();
expect(formatter(0.4)).toBe('0.4bps');
const fromBytes = createThroughputFormatter({ fromBytes: true });
expect(fromBytes(0.05)).toBe('0.4bps');
});
test('formats negative rates', () => {
const formatter = createThroughputFormatter();
expect(formatter(-1500)).toBe('-1.5kbps');
const fromBytes = createThroughputFormatter({ fromBytes: true });
expect(fromBytes(-187500)).toBe('-1.5Mbps');
});
test('rounds according to the decimals option', () => {
const formatter0decimals = createThroughputFormatter({ decimals: 0 });
expect(formatter0decimals(0)).toBe('0bps');
expect(formatter0decimals(8888)).toBe('9kbps');
const formatter3decimals = createThroughputFormatter({ decimals: 3 });
expect(formatter3decimals(8888)).toBe('8.888kbps');
});
@@ -53,6 +53,27 @@ test('LRU operations', () => {
expect(cache.capacity).toBe(3);
});
test('overwriting an existing key does not evict another entry', () => {
const cache = lruCache<string>(2);
cache.set('a', 'a');
cache.set('b', 'b');
cache.set('b', 'b2');
expect(cache.size).toBe(2);
expect(cache.has('a')).toBe(true);
expect(cache.get('b')).toBe('b2');
});
test('overwriting an existing key refreshes its recency', () => {
const cache = lruCache<string>(2);
cache.set('a', 'a');
cache.set('b', 'b');
// `a` becomes the most recently used, so `b` is evicted next
cache.set('a', 'a2');
cache.set('c', 'c');
expect(cache.has('b')).toBe(false);
expect(cache.values()).toEqual(['a2', 'c']);
});
test('LRU handle null and undefined', () => {
const cache = lruCache();
cache.set('a', null);
@@ -42,6 +42,7 @@ import {
import { getDatasetByName } from '../../helpers/api/dataset';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import { DashboardPage } from '../../pages/DashboardPage';
import { TIMEOUT } from '../../utils/constants';
import {
buildFilterJsonMetadata,
buildSelectFilter,
@@ -54,6 +55,11 @@ const FILTER_VALUE = 'boy';
testWithAssets(
'Mixed chart applies dashboard filter to both queries (#29519)',
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
// their fixtures over the API rather than importing them.
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
@@ -28,6 +28,14 @@ import { convertUTCTimestampToLocal } from './utils';
// Define a minimal constructor interface for use in this file.
interface CalHeatMapInstance {
init(config: Record<string, unknown>): void;
destroy(): null;
}
const calendarInstances = new WeakMap<HTMLElement, CalHeatMapInstance[]>();
export function destroyCalendarInstances(element: HTMLElement) {
calendarInstances.get(element)?.forEach(calendar => calendar.destroy());
calendarInstances.delete(element);
}
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
@@ -82,6 +90,10 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
colorRangeStart,
} = props;
destroyCalendarInstances(element);
const instances: CalHeatMapInstance[] = [];
calendarInstances.set(element, instances);
const container = d3Select(element)
.classed('superset-legacy-chart-calendar', true)
.style('height', height);
@@ -120,12 +132,13 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
const colorScheme = getSequentialSchemeRegistry().get(linearColorScheme);
const colorScale = colorScheme
? colorScheme.createLinearScale(extents)
: (_v: number) => '#ccc'; // fallback if scheme not found
: () => '#ccc'; // fallback if scheme not found
const legend = d3Range(steps).map(i => extents[0] + step * i);
const legendColors = legend.map(x => colorScale(x));
const cal = new CalHeatMap();
instances.push(cal);
cal.init({
start: convertUTCTimestampToLocal(data.start),
data: timestamps,
@@ -19,7 +19,13 @@
import { reactify } from '@superset-ui/core';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { Global } from '@emotion/react';
import Component from './Calendar';
import Component, { destroyCalendarInstances } from './Calendar';
function componentWillUnmount(this: { container?: HTMLDivElement }) {
if (this.container) {
destroyCalendarInstances(this.container);
}
}
// Type-erase the render function to allow flexible prop spreading in the wrapper.
// The Calendar render function has typed props, but the wrapper passes props via spread
@@ -29,6 +35,7 @@ const ReactComponent = reactify(
container: HTMLDivElement,
props: Record<string, unknown>,
) => void,
{ componentWillUnmount },
);
interface CalendarWrapperProps {
@@ -0,0 +1,20 @@
/**
* 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.
*/
export const CALENDAR_TOOLTIP_CLASS = 'superset-legacy-chart-calendar-tooltip';
@@ -12,6 +12,7 @@
import d3tip from 'd3-tip';
import { t } from '@apache-superset/core/translation';
import { getContrastingColor } from '@superset-ui/core';
import { CALENDAR_TOOLTIP_CLASS } from '../tooltip';
var d3 = typeof require === 'function' ? require('d3') : window.d3;
@@ -22,7 +23,7 @@ var CalHeatMap = function () {
var self = this;
self.tip = d3tip()
.attr('class', 'd3-tip')
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
.direction('n')
.offset([-5, 0])
.html(
@@ -33,7 +34,7 @@ var CalHeatMap = function () {
`,
);
self.legendTip = d3tip()
.attr('class', 'd3-tip')
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
.direction('n')
.offset([-5, 0])
.html(d => self.options.valueFormatter(d));
@@ -3444,6 +3445,14 @@ CalHeatMap.prototype = {
destroy: function (callback) {
'use strict';
this.tip.destroy();
this.legendTip.destroy();
// init() can fail validation before creating the calendar root.
if (!this.root) {
return null;
}
this.root
.transition()
.duration(this.options.animationDuration)
@@ -0,0 +1,585 @@
/**
* 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 { Component, memo, StrictMode, type ReactNode } from 'react';
import {
act,
cleanup,
render,
screen,
waitFor,
} from 'spec/helpers/testing-library';
import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
interface MockCalHeatMapConfig {
itemSelector: Element;
}
type MetricNameInput = string | string[];
let mockNextInstanceId = 0;
let mockNextOwnerId = 0;
let mockInitCallCount = 0;
let mockThrowOnInitCall: number | null = null;
let mockDestroyCallCount = 0;
let mockDestroyedInstanceIds: string[] = [];
const mockTheme = {
colorBgElevated: '#ffffff',
};
jest.mock('../src/vendor/cal-heatmap', () => ({
__esModule: true,
default: class MockCalHeatMap {
private instanceId?: string;
private tooltips: HTMLElement[] = [];
init(config: MockCalHeatMapConfig) {
const {
CALENDAR_TOOLTIP_CLASS: mockCalendarTooltipClass,
} = require('../src/tooltip');
mockInitCallCount += 1;
if (mockThrowOnInitCall === mockInitCallCount) {
throw new Error('Mock CalHeatMap init failure');
}
mockNextInstanceId += 1;
this.instanceId = String(mockNextInstanceId);
const owner = config.itemSelector.closest(
'.superset-legacy-chart-calendar',
) as HTMLElement | null;
if (!owner) {
throw new Error('Expected tooltip owner calendar container');
}
if (!owner.dataset.tooltipOwner) {
mockNextOwnerId += 1;
owner.dataset.tooltipOwner = `calendar-owner-${mockNextOwnerId}`;
}
const { tooltipOwner } = owner.dataset;
if (!tooltipOwner) {
throw new Error('Expected owner-specific tooltip marker');
}
this.tooltips = [0, 1].map(index => {
const tooltip = global.document.createElement('div');
tooltip.className = ['d3-tip', mockCalendarTooltipClass].join(' ');
tooltip.dataset.tooltipOwner = tooltipOwner;
tooltip.dataset.tooltipInstance = this.instanceId;
tooltip.dataset.tooltipIndex = String(index);
tooltip.textContent = `${tooltipOwner}-tooltip-${this.instanceId}-${index}`;
global.document.body.appendChild(tooltip);
return tooltip;
});
}
destroy() {
mockDestroyCallCount += 1;
if (this.instanceId) {
mockDestroyedInstanceIds.push(this.instanceId);
}
this.tooltips.forEach(tooltip => tooltip.remove());
this.tooltips = [];
return null;
}
},
}));
const ReactCalendar = require('../src/ReactCalendar').default;
const Calendar = require('../src/Calendar').default;
interface CalendarHarnessProps {
firstMetricNames: MetricNameInput;
secondMetricNames?: MetricNameInput;
showFirstCalendar?: boolean;
}
interface CalendarErrorBoundaryProps {
children: ReactNode;
}
interface CalendarErrorBoundaryState {
hasError: boolean;
}
const CALENDAR_START = 1704067200000;
function createCalendarProps(metricNames: MetricNameInput) {
const normalizedMetricNames = Array.isArray(metricNames)
? metricNames
: [metricNames];
return {
data: {
data: Object.fromEntries(
normalizedMetricNames.map((metricName, index) => [
metricName,
{
[String(CALENDAR_START)]: index * 2 + 1,
[String(CALENDAR_START + 86400000)]: index * 2 + 2,
},
]),
),
domain: 'month',
range: 1,
start: CALENDAR_START,
subdomain: 'day',
},
height: 160,
domainGranularity: 'month',
linearColorScheme: 'schemeRdYlBu',
showLegend: false,
showMetricName: true,
showValues: false,
steps: 3,
subdomainGranularity: 'day',
timeFormatter: (value: number | string) => String(value),
valueFormatter: (value: number) => String(value),
verboseMap: Object.fromEntries(
normalizedMetricNames.map(metricName => [metricName, metricName]),
),
};
}
class CalendarErrorBoundary extends Component<
CalendarErrorBoundaryProps,
CalendarErrorBoundaryState
> {
state: CalendarErrorBoundaryState = {
hasError: false,
};
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>Calendar fallback</div>;
}
return this.props.children;
}
}
const StableSecondCalendar = memo(function StableSecondCalendar({
metricNames,
}: {
metricNames: MetricNameInput;
}) {
return (
<div data-test="calendar-second">
<ReactCalendar {...createCalendarProps(metricNames)} />
</div>
);
});
function CalendarHarness({
firstMetricNames,
secondMetricNames = 'second-metric',
showFirstCalendar = true,
}: CalendarHarnessProps) {
return (
<>
{showFirstCalendar ? (
<div data-test="calendar-first">
<ReactCalendar {...createCalendarProps(firstMetricNames)} />
</div>
) : null}
<StableSecondCalendar metricNames={secondMetricNames} />
</>
);
}
function getCalendarOwner(testId: string) {
const owner = screen
.getByTestId(testId)
.querySelector('.superset-legacy-chart-calendar');
if (!(owner instanceof HTMLElement)) {
throw new Error(`Expected mounted calendar owner for ${testId}`);
}
return owner;
}
function getTooltipOwnerId(owner: HTMLElement) {
const tooltipOwnerId = owner.dataset.tooltipOwner;
if (!tooltipOwnerId) {
throw new Error('Expected mounted calendar to have a tooltip owner id');
}
return tooltipOwnerId;
}
function getOwnerTooltips(tooltipOwnerId: string) {
return Array.from(
document.querySelectorAll<HTMLElement>(
`.d3-tip[data-tooltip-owner="${tooltipOwnerId}"]`,
),
);
}
function getCalendarTooltips() {
return Array.from(
document.querySelectorAll<HTMLElement>(`.${CALENDAR_TOOLTIP_CLASS}`),
);
}
function getTooltipInstanceIds(tooltips: HTMLElement[]) {
return Array.from(
new Set(tooltips.map(tooltip => tooltip.dataset.tooltipInstance)),
)
.filter((tooltipInstanceId): tooltipInstanceId is string =>
Boolean(tooltipInstanceId),
)
.sort();
}
function getSingleTooltipInstanceId(tooltips: HTMLElement[]) {
const tooltipInstanceIds = getTooltipInstanceIds(tooltips);
expect(tooltipInstanceIds).toHaveLength(1);
return tooltipInstanceIds[0];
}
function flushPendingTimersIfNeeded() {
try {
act(() => {
jest.runOnlyPendingTimers();
});
} catch {
// Ignore tests that did not opt into fake timers.
}
}
afterEach(() => {
cleanup();
flushPendingTimersIfNeeded();
jest.useRealTimers();
mockNextInstanceId = 0;
mockNextOwnerId = 0;
mockInitCallCount = 0;
mockThrowOnInitCall = null;
mockDestroyCallCount = 0;
mockDestroyedInstanceIds = [];
document.body.innerHTML = '';
});
test('rerender and unmount clean up only the affected calendar tooltips', () => {
jest.useFakeTimers();
const { rerender, unmount } = render(
<CalendarHarness firstMetricNames="first-metric-initial" />,
);
const firstCalendarOwner = getCalendarOwner('calendar-first');
const secondCalendarOwner = getCalendarOwner('calendar-second');
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
const firstInitialTooltips = getOwnerTooltips(firstTooltipOwnerId);
const secondInitialTooltips = getOwnerTooltips(secondTooltipOwnerId);
expect(firstInitialTooltips).toHaveLength(2);
expect(secondInitialTooltips).toHaveLength(2);
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
4,
);
const firstInitialInstanceId =
getSingleTooltipInstanceId(firstInitialTooltips);
const secondInitialInstanceId = getSingleTooltipInstanceId(
secondInitialTooltips,
);
rerender(<CalendarHarness firstMetricNames="first-metric-rerendered" />);
const firstRerenderedTooltips = getOwnerTooltips(firstTooltipOwnerId);
const secondPreservedTooltips = getOwnerTooltips(secondTooltipOwnerId);
expect(firstRerenderedTooltips).toHaveLength(2);
expect(secondPreservedTooltips).toHaveLength(2);
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
4,
);
expect(firstInitialTooltips.every(tooltip => !tooltip.isConnected)).toBe(
true,
);
expect(secondInitialTooltips.every(tooltip => tooltip.isConnected)).toBe(
true,
);
expect(getSingleTooltipInstanceId(firstRerenderedTooltips)).not.toEqual(
firstInitialInstanceId,
);
expect(getSingleTooltipInstanceId(secondPreservedTooltips)).toEqual(
secondInitialInstanceId,
);
rerender(
<CalendarHarness
firstMetricNames="first-metric-rerendered"
showFirstCalendar={false}
/>,
);
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
2,
);
expect(
getSingleTooltipInstanceId(getOwnerTooltips(secondTooltipOwnerId)),
).toEqual(secondInitialInstanceId);
unmount();
});
test('multi-metric calendar rerender and unmount clean up every owned tooltip while preserving siblings', () => {
jest.useFakeTimers();
const { rerender, unmount } = render(
<CalendarHarness
firstMetricNames={['first-metric-a', 'first-metric-b']}
secondMetricNames="second-metric-initial"
/>,
);
const firstCalendarOwner = getCalendarOwner('calendar-first');
const secondCalendarOwner = getCalendarOwner('calendar-second');
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
const firstInitialTooltips = getOwnerTooltips(firstTooltipOwnerId);
const secondInitialTooltips = getOwnerTooltips(secondTooltipOwnerId);
expect(firstInitialTooltips).toHaveLength(4);
expect(secondInitialTooltips).toHaveLength(2);
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
6,
);
const firstInitialInstanceIds = getTooltipInstanceIds(firstInitialTooltips);
const secondInitialInstanceIds = getTooltipInstanceIds(secondInitialTooltips);
expect(firstInitialInstanceIds).toHaveLength(2);
expect(secondInitialInstanceIds).toHaveLength(1);
rerender(
<CalendarHarness
firstMetricNames={['first-metric-c', 'first-metric-d']}
secondMetricNames="second-metric-initial"
/>,
);
const firstRerenderedTooltips = getOwnerTooltips(firstTooltipOwnerId);
const secondPreservedTooltips = getOwnerTooltips(secondTooltipOwnerId);
expect(firstRerenderedTooltips).toHaveLength(4);
expect(secondPreservedTooltips).toHaveLength(2);
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
6,
);
expect(firstInitialTooltips.every(tooltip => !tooltip.isConnected)).toBe(
true,
);
expect(secondInitialTooltips.every(tooltip => tooltip.isConnected)).toBe(
true,
);
expect(getTooltipInstanceIds(firstRerenderedTooltips)).toHaveLength(2);
expect(getTooltipInstanceIds(firstRerenderedTooltips)).not.toEqual(
firstInitialInstanceIds,
);
expect(getTooltipInstanceIds(secondPreservedTooltips)).toEqual(
secondInitialInstanceIds,
);
rerender(
<CalendarHarness
firstMetricNames={['first-metric-c', 'first-metric-d']}
secondMetricNames="second-metric-initial"
showFirstCalendar={false}
/>,
);
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
2,
);
expect(getTooltipInstanceIds(getOwnerTooltips(secondTooltipOwnerId))).toEqual(
secondInitialInstanceIds,
);
unmount();
});
test('Calendar destroys previously initialized metric instances after a later metric init failure', () => {
const calendarOwner = document.createElement('div');
document.body.appendChild(calendarOwner);
mockThrowOnInitCall = 2;
expect(() => {
Calendar(calendarOwner, {
...createCalendarProps(['failing-metric-a', 'failing-metric-b']),
theme: mockTheme,
});
}).toThrow('Mock CalHeatMap init failure');
const failedRenderTooltips = getOwnerTooltips(
getTooltipOwnerId(calendarOwner),
);
const failedRenderInstanceIds = getTooltipInstanceIds(failedRenderTooltips);
expect(failedRenderTooltips).toHaveLength(2);
expect(failedRenderInstanceIds).toHaveLength(1);
mockThrowOnInitCall = null;
Calendar(calendarOwner, {
...createCalendarProps('recovered-metric'),
theme: mockTheme,
});
const recoveredTooltips = getOwnerTooltips(getTooltipOwnerId(calendarOwner));
expect(failedRenderTooltips.every(tooltip => !tooltip.isConnected)).toBe(
true,
);
expect(recoveredTooltips).toHaveLength(2);
expect(getTooltipInstanceIds(recoveredTooltips)).not.toEqual(
failedRenderInstanceIds,
);
});
test('surviving calendar rerender preserves sibling cleanup after another calendar unmounts', () => {
jest.useFakeTimers();
const { rerender, unmount } = render(
<CalendarHarness
firstMetricNames="first-metric-initial"
secondMetricNames="second-metric-initial"
/>,
);
const firstCalendarOwner = getCalendarOwner('calendar-first');
const secondCalendarOwner = getCalendarOwner('calendar-second');
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
const secondInitialInstanceId = getSingleTooltipInstanceId(
getOwnerTooltips(secondTooltipOwnerId),
);
rerender(
<CalendarHarness
firstMetricNames="first-metric-initial"
secondMetricNames="second-metric-initial"
showFirstCalendar={false}
/>,
);
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
2,
);
rerender(
<CalendarHarness
firstMetricNames="first-metric-initial"
secondMetricNames="second-metric-rerendered"
showFirstCalendar={false}
/>,
);
const secondRerenderedTooltips = getOwnerTooltips(secondTooltipOwnerId);
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
expect(secondRerenderedTooltips).toHaveLength(2);
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
2,
);
expect(getSingleTooltipInstanceId(secondRerenderedTooltips)).not.toEqual(
secondInitialInstanceId,
);
unmount();
});
test('StrictMode unmount cleans up calendar tooltips for multi-metric calendars', () => {
const { unmount } = render(
<StrictMode>
<ReactCalendar
{...createCalendarProps(['strict-metric-a', 'strict-metric-b'])}
/>
</StrictMode>,
);
expect(getCalendarTooltips()).toHaveLength(4);
unmount();
expect(getCalendarTooltips()).toHaveLength(0);
});
test('ErrorBoundary fallback destroys partially initialized tooltips after a later init failure', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
mockThrowOnInitCall = 2;
try {
const { unmount } = render(
<CalendarErrorBoundary>
<ReactCalendar
{...createCalendarProps(['failing-metric-a', 'failing-metric-b'])}
/>
</CalendarErrorBoundary>,
);
await waitFor(() => {
expect(screen.getByText('Calendar fallback')).toBeInTheDocument();
});
await waitFor(() => {
expect(getCalendarTooltips()).toHaveLength(0);
});
expect(mockDestroyCallCount).toBe(2);
expect(mockDestroyedInstanceIds).toEqual(['1']);
unmount();
expect(getCalendarTooltips()).toHaveLength(0);
} finally {
consoleErrorSpy.mockRestore();
}
});
@@ -0,0 +1,136 @@
/**
* 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 { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
// The vendor file is @ts-nocheck, so its export lacks type info.
// Mirror the minimal constructor interface defined in Calendar.ts.
interface CalHeatMapInstance {
init(config: Record<string, unknown>): void;
destroy(): null;
}
const createSVGPointDescriptor = Object.getOwnPropertyDescriptor(
window.SVGSVGElement.prototype,
'createSVGPoint',
);
function installCreateSVGPointMock() {
Object.defineProperty(window.SVGSVGElement.prototype, 'createSVGPoint', {
configurable: true,
value: () => ({
matrixTransform: () => ({ x: 0, y: 0 }),
}),
});
}
function restoreCreateSVGPointMock() {
if (createSVGPointDescriptor) {
Object.defineProperty(
window.SVGSVGElement.prototype,
'createSVGPoint',
createSVGPointDescriptor,
);
} else {
delete (
window.SVGSVGElement.prototype as Partial<
Pick<SVGSVGElement, 'createSVGPoint'>
>
).createSVGPoint;
}
}
function getCalendarTooltips() {
return Array.from(
document.querySelectorAll<HTMLElement>(`.${CALENDAR_TOOLTIP_CLASS}`),
);
}
afterEach(() => {
jest.resetModules();
restoreCreateSVGPointMock();
document.body.innerHTML = '';
});
test('CalHeatMap destroy tolerates partially initialized instances', () => {
let CalHeatMap!: new () => CalHeatMapInstance;
jest.isolateModules(() => {
// eslint-disable-next-line global-require
CalHeatMap = require('../src/vendor/cal-heatmap')
.default as new () => CalHeatMapInstance;
});
const partiallyInitializedHeatmap = new CalHeatMap();
expect(() => partiallyInitializedHeatmap.destroy()).not.toThrow();
expect(getCalendarTooltips()).toHaveLength(0);
});
test('CalHeatMap destroy removes only the destroyed instance tooltips', () => {
installCreateSVGPointMock();
let CalHeatMap!: new () => CalHeatMapInstance;
jest.isolateModules(() => {
// eslint-disable-next-line global-require
CalHeatMap = require('../src/vendor/cal-heatmap')
.default as new () => CalHeatMapInstance;
});
const firstCalendar = document.createElement('div');
const secondCalendar = document.createElement('div');
document.body.append(firstCalendar, secondCalendar);
const firstHeatmap = new CalHeatMap();
firstHeatmap.init({
itemSelector: firstCalendar,
paintOnLoad: false,
tooltip: true,
valueFormatter: String,
timeFormatter: String,
});
const firstTooltips = getCalendarTooltips();
expect(firstTooltips).toHaveLength(2);
const secondHeatmap = new CalHeatMap();
secondHeatmap.init({
itemSelector: secondCalendar,
paintOnLoad: false,
tooltip: true,
valueFormatter: String,
timeFormatter: String,
});
const allTooltips = getCalendarTooltips();
const secondTooltips = allTooltips.filter(
tooltip => !firstTooltips.includes(tooltip),
);
expect(secondTooltips).toHaveLength(2);
firstHeatmap.destroy();
expect(firstTooltips.every(tooltip => !tooltip.isConnected)).toBe(true);
expect(secondTooltips.every(tooltip => tooltip.isConnected)).toBe(true);
expect(getCalendarTooltips()).toHaveLength(2);
secondHeatmap.destroy();
expect(getCalendarTooltips()).toHaveLength(0);
});
@@ -690,11 +690,11 @@ export default function transformProps(
})()
: xAxisFormatter;
const yAxisTitleMarginPx = convertInteger(yAxisTitleMargin);
const xAxisTitleMarginPx = convertInteger(xAxisTitleMargin);
const addYAxisTitleOffset =
!!(yAxisTitle || yAxisTitleSecondary) &&
convertInteger(yAxisTitleMargin) !== 0;
const addXAxisTitleOffset =
!!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0;
!!(yAxisTitle || yAxisTitleSecondary) && yAxisTitleMarginPx !== 0;
const addXAxisTitleOffset = !!xAxisTitle && xAxisTitleMarginPx !== 0;
const baseChartPadding = getPadding(
showLegend,
legendOrientation,
@@ -703,8 +703,8 @@ export default function transformProps(
legendMargin,
addXAxisTitleOffset,
yAxisTitlePosition,
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
yAxisTitleMarginPx,
xAxisTitleMarginPx,
);
const legendData = series
.filter(
@@ -748,8 +748,8 @@ export default function transformProps(
effectiveLegendMargin,
addXAxisTitleOffset,
yAxisTitlePosition,
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
yAxisTitleMarginPx,
xAxisTitleMarginPx,
);
const { setDataMask = () => {}, onContextMenu } = hooks;
@@ -764,7 +764,7 @@ export default function transformProps(
xAxis: {
type: xAxisType,
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameGap: xAxisTitleMarginPx,
nameLocation: 'middle',
axisLabel: {
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
@@ -821,7 +821,7 @@ export default function transformProps(
},
scale: truncateYAxis,
name: yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
nameGap: yAxisTitleMarginPx,
nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
alignTicks,
},
@@ -844,6 +844,8 @@ export default function transformProps(
},
scale: truncateYAxis,
name: yAxisTitleSecondary,
nameGap: yAxisTitleMarginPx,
nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
alignTicks,
},
],
@@ -55,15 +55,19 @@ import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { getContributionLabel } from './utils';
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
const defaultPercentFormatter = getNumberFormatter(
NumberFormats.PERCENT_2_POINT,
);
export function parseParams({
params,
numberFormatter,
percentFormatter = defaultPercentFormatter,
sanitizeName = false,
}: {
params: Pick<CallbackDataParams, 'name' | 'value' | 'percent'>;
numberFormatter: ValueFormatter;
percentFormatter?: ValueFormatter;
sanitizeName?: boolean;
}): string[] {
const { name: rawName = '', value, percent } = params;
@@ -296,6 +300,9 @@ export default function transformProps(
currencyCodeColumn,
detectedCurrency,
);
const percentFormatter = numberFormat?.endsWith('%')
? getNumberFormatter(numberFormat)
: defaultPercentFormatter;
let data = rawData;
const otherRows: DataRecord[] = [];
@@ -446,6 +453,7 @@ export default function transformProps(
const [name, formattedValue, formattedPercent] = parseParams({
params,
numberFormatter,
percentFormatter,
});
switch (labelType) {
case EchartsPieLabelType.Key:
@@ -566,6 +574,7 @@ export default function transformProps(
const [name, formattedValue, formattedPercent] = parseParams({
params,
numberFormatter,
percentFormatter,
sanitizeName: true,
});
if (params?.data?.isOther) {
@@ -318,6 +318,14 @@ export default function transformProps(
rebaseToPercentChange(forecastRebasedData, xAxisLabel || DTTM_ALIAS)
: forecastRebasedData;
const isHorizontal = orientation === OrientationType.Horizontal;
// rebasedData's keys have already been through rebaseForecastDatum, which
// renames a key to its verboseMap entry when one is configured for that
// metric. extraMetricLabels must be mapped the same way, or a sort-only
// metric with a verbose_name set would silently fail to match here (and in
// extractSeries below, which has the same requirement).
const extraMetricLabels = extractExtraMetrics(chartProps.rawFormData)
.map(getMetricLabel)
.map(label => verboseMap[label] ?? label);
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
rebasedData,
{
@@ -325,11 +333,9 @@ export default function transformProps(
percentageThreshold,
xAxisCol: xAxisLabel,
legendState,
extraMetricLabels,
},
);
const extraMetricLabels = extractExtraMetrics(chartProps.rawFormData).map(
getMetricLabel,
);
const isMultiSeries = groupBy.length || metrics?.length > 1;
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
@@ -387,6 +387,7 @@ export function extractDataTotalValues(
percentageThreshold: number;
xAxisCol: string;
legendState?: LegendState;
extraMetricLabels?: string[];
},
): {
totalStackedValues: number[];
@@ -394,11 +395,18 @@ export function extractDataTotalValues(
} {
const totalStackedValues: number[] = [];
const thresholdValues: number[] = [];
const { stack, percentageThreshold, xAxisCol, legendState } = opts;
const {
stack,
percentageThreshold,
xAxisCol,
legendState,
extraMetricLabels,
} = opts;
const excludedKeys = new Set([xAxisCol, ...(extraMetricLabels ?? [])]);
if (stack) {
data.forEach(datum => {
const values = Object.keys(datum).reduce((prev, curr) => {
if (curr === xAxisCol) {
if (excludedKeys.has(curr)) {
return prev;
}
if (legendState && !legendState[curr]) {
@@ -1243,3 +1243,55 @@ test('regression #37921: multi-metric Query A with groupby does not duplicate fi
expect(name).not.toMatch(/score_one,\s+score_two/);
}
});
test('y-axis title position: Left sets nameLocation to middle', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: {
...formData,
yAxisTitlePosition: 'Left',
yAxisTitleMargin: 20,
},
queriesData,
});
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
const yAxis = transformed.echartOptions.yAxis as Array<{
nameGap: number;
nameLocation: string;
}>;
expect(yAxis[0].nameGap).toEqual(20);
expect(yAxis[0].nameLocation).toEqual('middle');
expect(yAxis[1].nameGap).toEqual(20);
expect(yAxis[1].nameLocation).toEqual('middle');
});
test('y-axis title position: non-Left sets nameLocation to end', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: {
...formData,
yAxisTitlePosition: 'Top',
yAxisTitleMargin: 30,
},
queriesData,
});
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
const yAxis = transformed.echartOptions.yAxis as Array<{
nameGap: number;
nameLocation: string;
}>;
expect(yAxis[0].nameGap).toEqual(30);
expect(yAxis[0].nameLocation).toEqual('end');
expect(yAxis[1].nameGap).toEqual(30);
expect(yAxis[1].nameLocation).toEqual('end');
});
@@ -311,6 +311,26 @@ describe('Pie label string template', () => {
).toEqual('Tablet:123,456\n55.50%');
});
test('should format percentages using a percentage number format', () => {
expect(
format({
label_type: 'template',
label_template: '{name}:{value}\n{percent}',
number_format: '.1%',
}),
).toEqual('Tablet:12345600.0%\n55.5%');
});
test('should use the default percentage format when number format is absent', () => {
expect(
format({
label_type: 'template',
label_template: '{name}:{value}\n{percent}',
number_format: undefined,
}),
).toEqual('Tablet:123k\n55.50%');
});
test('should be compatible with ECharts raw variable syntax', () => {
expect(
format({
@@ -755,6 +755,55 @@ describe('Does transformProps transform series correctly', () => {
});
});
test('should exclude a verbose-named sort-only metric from the stacked total (#42881)', () => {
// rebaseForecastDatum renames a data column to its verboseMap entry when
// one is configured, so extraMetricLabels (derived from raw metric
// labels) must be resolved through the same verboseMap to still match —
// otherwise the sort-only metric's value leaks back into the total.
const sortMetricVerboseMap = { sort_metric: 'Sort By Metric' };
const sortFormData: SqlaFormData = {
...formData,
onlyTotal: true,
groupby: [],
metrics: ['San Francisco', 'New York', 'Boston'],
timeseries_limit_metric: 'sort_metric',
x_axis_sort: 'sort_metric',
};
const sortQueriesData: ChartDataResponseResult[] = [
createTestQueryData(
createTestData(
[
{
'San Francisco': 32,
'New York': 0,
Boston: 0,
'Sort By Metric': 2,
},
],
{ intervalMs: 300000000 },
),
),
];
const chartProps = createTestChartProps({
formData: sortFormData,
queriesData: sortQueriesData,
datasource: { verboseMap: sortMetricVerboseMap },
});
const transformedSeries = transformProps(chartProps).echartOptions
.series as seriesType[];
const totalLabels = transformedSeries
.flatMap((series, seriesIndex) =>
series.data.map((value, dataIndex) =>
series.label.formatter({ value, dataIndex, seriesIndex }),
),
)
.filter(label => label !== '');
expect(totalLabels).toEqual(['32']);
});
test('should show labels on values >= percentageThreshold if onlyTotal is false', () => {
const chartProps = createTestChartProps({ formData, queriesData });
@@ -28,6 +28,7 @@ import { GenericDataType } from '@apache-superset/core/common';
import {
calculateLowerLogTick,
dedupSeries,
extractDataTotalValues,
extractGroupbyLabel,
extractSeries,
extractShowValueIndexes,
@@ -467,6 +468,40 @@ test('sortAndFilterSeries by name with numbers desc', () => {
]);
});
test('extractDataTotalValues excludes extraMetricLabels from the stacked total (#42701)', () => {
const data: DataRecord[] = [
{ category: '1-3d', A: 32, B: 0, Sort: 2 },
{ category: '4-6d', A: 10, B: 5, Sort: 1 },
];
const withoutExclusion = extractDataTotalValues(data, {
stack: true,
percentageThreshold: 0,
xAxisCol: 'category',
});
// Reproduces the bug: the sort-only metric leaks into the total.
expect(withoutExclusion.totalStackedValues).toEqual([34, 16]);
const withExclusion = extractDataTotalValues(data, {
stack: true,
percentageThreshold: 0,
xAxisCol: 'category',
extraMetricLabels: ['Sort'],
});
expect(withExclusion.totalStackedValues).toEqual([32, 15]);
});
test('extractDataTotalValues still respects legendState alongside extraMetricLabels', () => {
const data: DataRecord[] = [{ category: '1-3d', A: 32, B: 8, Sort: 2 }];
const result = extractDataTotalValues(data, {
stack: true,
percentageThreshold: 0,
xAxisCol: 'category',
extraMetricLabels: ['Sort'],
legendState: { A: true, B: false },
});
expect(result.totalStackedValues).toEqual([32]);
});
describe('extractSeries', () => {
test('should generate a valid ECharts timeseries series object', () => {
const data = [
@@ -34,7 +34,7 @@
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.24",
"@types/lodash": "^4.17.25",
"jest": "^30.4.2"
},
"peerDependencies": {
@@ -27,7 +27,7 @@
"access": "public"
},
"devDependencies": {
"@babel/types": "^7.29.7",
"@babel/types": "^7.29.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
@@ -41,7 +41,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@types/lodash": "*",
"@types/lodash": "^4.17.25",
"@types/react": "*",
"react": "^18.3.0"
}
@@ -22,7 +22,7 @@ import { createEvent, fireEvent, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import type { ReactElement } from 'react';
import Legend from './Legend';
import Legend, { type LegendProps } from './Legend';
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
@@ -31,6 +31,7 @@ test('formats interval-notation labels while preserving brackets', () => {
renderWithTheme(
<Legend
format=",.2f"
position="tr"
categories={{
'[1, 81)': { enabled: true, color: [0, 0, 0] },
'[81, 212)': { enabled: true, color: [0, 0, 0] },
@@ -48,6 +49,7 @@ test('still formats legacy "a - b" delimiter labels', () => {
renderWithTheme(
<Legend
format=",.1f"
position="tr"
categories={{
'0 - 100000': { enabled: true, color: [0, 0, 0] },
'100001 - 200000': { enabled: true, color: [0, 0, 0] },
@@ -63,6 +65,7 @@ test('leaves labels untouched when no format is provided', () => {
renderWithTheme(
<Legend
format={null}
position="tr"
categories={{ '[1, 81)': { enabled: true, color: [0, 0, 0] } }}
/>,
);
@@ -78,6 +81,7 @@ test('clicking a legend item toggles the category without triggering anchor navi
renderWithTheme(
<Legend
format={null}
position="tr"
categories={{
Positive: { enabled: true, color: [0, 255, 0] },
Negative: { enabled: true, color: [255, 0, 0] },
@@ -95,6 +99,21 @@ test('clicking a legend item toggles the category without triggering anchor navi
expect(toggleCategory).toHaveBeenCalledWith('Positive');
});
test('normalizes the 0-255 deck.gl alpha channel to the 0-1 range CSS rgba() expects', () => {
renderWithTheme(
<Legend
format={null}
categories={{
Translucent: { enabled: true, color: [255, 0, 0, 128] },
}}
/>,
);
const legendItem = screen.getByRole('button', { name: 'Translucent' });
const swatch = legendItem.firstChild as HTMLElement;
expect(swatch).toHaveStyle(`background-color: rgba(255, 0, 0, ${128 / 255})`);
});
test('ctrl+clicking a legend item toggles the category without opening a new tab', () => {
// Regression proof for #34157: legend items render as real <button>
// elements (not href="#" anchors), so a ctrl+click has no "open link in
@@ -103,6 +122,7 @@ test('ctrl+clicking a legend item toggles the category without opening a new tab
renderWithTheme(
<Legend
format={null}
position="tr"
categories={{
cat1: { enabled: true, color: [255, 0, 0] },
cat2: { enabled: false, color: [0, 0, 255] },
@@ -122,3 +142,47 @@ test('ctrl+clicking a legend item toggles the category without opening a new tab
expect(toggleCategory).toHaveBeenCalledTimes(1);
expect(toggleCategory).toHaveBeenCalledWith('cat1');
});
// Regression proof for the "Legend Position: None" control not hiding the
// legend. "None" is the 'none' sentinel; null and '' also hide so charts saved
// under the older null-valued choice keep working.
test.each(['none', null, ''])(
'renders nothing when Legend Position is None (position=%p)',
position => {
renderWithTheme(
<Legend
format={null}
position={position as LegendProps['position']}
categories={{ Alpha: { enabled: true, color: [0, 0, 0] } }}
/>,
);
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
},
);
test('renders the legend for a valid corner position', () => {
renderWithTheme(
<Legend
format={null}
position="tr"
categories={{ Alpha: { enabled: true, color: [0, 0, 0] } }}
/>,
);
expect(screen.getByText('Alpha')).toBeInTheDocument();
});
test('falls back to the top-right default when position is unset', () => {
// Layers without a Legend Position control (e.g. Hex, Path) pass an
// undefined position; the legend must still render at the default corner.
renderWithTheme(
<Legend
format={null}
position={undefined}
categories={{ Alpha: { enabled: true, color: [0, 0, 0] } }}
/>,
);
expect(screen.getByText('Alpha')).toBeInTheDocument();
});
@@ -95,7 +95,7 @@ const parseInterval = (label: string) => {
export type LegendProps = {
format: string | null;
forceCategorical?: boolean;
position?: null | 'tl' | 'tr' | 'bl' | 'br';
position?: null | 'none' | 'tl' | 'tr' | 'bl' | 'br';
categories: Record<string, { enabled: boolean; color: Color | undefined }>;
toggleCategory?: (key: string) => void;
showSingleCategory?: (key: string) => void;
@@ -142,12 +142,27 @@ const Legend = ({
return format(k);
};
if (Object.keys(categoriesObject).length === 0 || position === null) {
// Hide the legend when there are no categories, or when Legend Position is
// "None". "None" is the 'none' sentinel from the control; null/'' are also
// treated as hidden so charts saved under the older null-valued choice keep
// working. An unset position (undefined) keeps the 'tr' default so layers
// without a Legend Position control (e.g. Hex, Path) still show their legend.
if (
Object.keys(categoriesObject).length === 0 ||
!position ||
position === 'none'
) {
return null;
}
const categories = Object.entries(categoriesObject).map(([k, v]) => {
const color = `rgba(${v.color?.join(', ')})`;
const [r, g, b, a] = v.color ?? [];
// deck.gl colour arrays express alpha as 0-255, but CSS rgba() expects
// an alpha channel of 0-1, so it must be normalized before use.
const color =
a === undefined
? `rgba(${r}, ${g}, ${b})`
: `rgba(${r}, ${g}, ${b}, ${a / 255})`;
// Render the swatch as a real coloured box rather than a colour-tinted
// text glyph. U+25FC/U+25FB are in Unicode's Emoji set but lack
// Emoji_Presentation, so Chromium resolves them to a colour-emoji font
@@ -23,6 +23,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import DeckGLPolygon, { getPoints } from './Polygon';
import type { LegendProps } from '../../components/Legend';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import * as utils from '../../utils';
@@ -53,15 +54,26 @@ jest.mock('../../utils/mapbox', () => ({
hasMapboxApiKey: () => true,
}));
jest.mock('../../components/Legend', () => ({ categories, position }: any) => (
<div
data-testid="legend"
data-categories={JSON.stringify(categories)}
data-position={position}
>
Legend Mock
</div>
));
// Stand in for the real Legend, exposing the props it received so the layer's
// wiring can be asserted. The hide-on-"none" behavior belongs to the real
// Legend (covered in Legend.test.tsx), so the mock does not reimplement it.
// Emits data-test (the configured testIdAttribute) so screen queries resolve.
jest.mock(
'../../components/Legend',
() =>
({
categories,
position,
}: Pick<LegendProps, 'categories' | 'position'>) => (
<div
data-test="legend"
data-categories={JSON.stringify(categories)}
data-position={String(position)}
>
Legend Mock
</div>
),
);
const mockProps = {
formData: {
@@ -369,20 +381,26 @@ describe('DeckGLPolygon Error Handling and Edge Cases', () => {
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('handles null legend_position correctly', () => {
const propsWithNullLegendPosition = {
...mockProps,
formData: {
...mockProps.formData,
legend_position: null,
},
};
// The layer forwards formData.legend_position to the Legend's position prop
// unchanged; hiding for the "none" sentinel is the Legend's responsibility
// (covered in Legend.test.tsx). Asserting the wiring here avoids
// re-implementing the hide gate in the mock.
test.each(['none', 'tr'])(
'forwards legend_position "%s" to the Legend',
legendPosition => {
const props = {
...mockProps,
formData: { ...mockProps.formData, legend_position: legendPosition },
};
renderWithTheme(<DeckGLPolygon {...propsWithNullLegendPosition} />);
renderWithTheme(<DeckGLPolygon {...props} />);
// Legend should not be rendered when position is null
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
});
expect(screen.getByTestId('legend')).toHaveAttribute(
'data-position',
legendPosition,
);
},
);
});
describe('DeckGLPolygon Legend Integration', () => {
@@ -398,16 +416,16 @@ describe('DeckGLPolygon Legend Integration', () => {
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {
const { container } = renderWithTheme(<DeckGLPolygon {...mockProps} />);
renderWithTheme(<DeckGLPolygon {...mockProps} />);
// Verify the component renders and calls the correct bucket function
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
// Verify the legend mock was rendered with non-empty categories
const legendElement = container.querySelector('[data-testid="legend"]');
expect(legendElement).toBeTruthy();
const categoriesAttr = legendElement?.getAttribute('data-categories');
const legendElement = screen.getByTestId('legend');
expect(legendElement).toBeInTheDocument();
const categoriesAttr = legendElement.getAttribute('data-categories');
const categoriesData = JSON.parse(categoriesAttr || '{}');
expect(Object.keys(categoriesData)).toHaveLength(2);
});
@@ -17,6 +17,7 @@
* under the License.
*/
import { createElement } from 'react';
import { PickingInfo } from '@deck.gl/core';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import {
@@ -131,6 +132,32 @@ describe('commonLayerProps', () => {
});
});
test('clears a custom tooltip on hover-out instead of trailing the cursor', () => {
// Regression test for a custom (Handlebars) deck.gl tooltip that stayed
// visible and followed the mouse after leaving a feature.
const setTooltip = jest.fn();
const customContent = createElement('div', {
'data-tooltip-type': 'custom',
});
const props = commonLayerProps({
formData: { ...partialformData } as QueryFormData,
setTooltip: setTooltip as any,
setTooltipContent: (() => customContent) as any,
});
// Hovering a feature shows the custom tooltip.
props.onHover?.({ picked: true, x: 10, y: 20 } as any);
expect(setTooltip).toHaveBeenLastCalledWith({
content: customContent,
x: 10,
y: 20,
});
// Moving off the feature must dismiss it, not keep repositioning it.
props.onHover?.({ picked: false, x: 30, y: 40 } as any);
expect(setTooltip).toHaveBeenLastCalledWith(null);
});
test('calls onSelect when table_filter is enabled', () => {
const formData = {
...partialformData,
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, isValidElement } from 'react';
import { ReactNode } from 'react';
import {
ascending as d3ascending,
quantile as d3quantile,
@@ -70,19 +70,16 @@ export function commonLayerProps({
if (setTooltipContent) {
let currentTooltipContent: ReactNode = null;
const isCustomTooltip = (content: ReactNode): boolean =>
isValidElement(content) &&
content.props?.['data-tooltip-type'] === 'custom';
onHover = (o: JsonObject) => {
if (o.picked) {
currentTooltipContent = setTooltipContent(o);
}
if (
currentTooltipContent &&
(o.picked || isCustomTooltip(currentTooltipContent))
) {
// Only show the tooltip while a feature is actually hovered. Custom
// (Handlebars) tooltips used to stay visible and follow the cursor
// after hover-out because their content was kept on screen even when
// nothing was picked.
if (o.picked && currentTooltipContent) {
setTooltip({
content: currentTooltipContent,
x: o.x,
@@ -17,9 +17,13 @@
* under the License.
*/
import { ControlPanelState } from '@superset-ui/chart-controls';
import {
ControlPanelState,
ControlStateMapping,
} from '@superset-ui/chart-controls';
import { OSM_TILE_STYLE_URL } from '@superset-ui/core/utils/mapStyles';
import { mapProvider, maplibreStyle } from './Shared_DeckGL';
import { fillColorPicker, mapProvider, maplibreStyle } from './Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from './utils';
const setBootstrap = ({
conf = {},
@@ -159,3 +163,50 @@ test('deck.gl map style accepts well-formed tile overrides', () => {
]);
expect(props.default).toBe('https://tiles.example.com/style.json');
});
// fillColorPicker visibility tests
type FillColorPickerConfig = typeof fillColorPicker.config & {
visibility: (ctx: { controls: ControlStateMapping }) => boolean;
};
const fillColorPickerVisibility = (controls: ControlStateMapping) =>
(fillColorPicker.config as FillColorPickerConfig).visibility({ controls });
const makeControls = (schemeType?: string): ControlStateMapping =>
schemeType === undefined
? ({} as ControlStateMapping)
: ({
color_scheme_type: { value: schemeType },
} as unknown as ControlStateMapping);
test('fillColorPicker is visible when color_scheme_type is absent (legacy form)', () => {
expect(fillColorPickerVisibility(makeControls(undefined))).toBe(true);
});
test('fillColorPicker is visible when color_scheme_type is fixed_color', () => {
expect(
fillColorPickerVisibility(makeControls(COLOR_SCHEME_TYPES.fixed_color)),
).toBe(true);
});
test('fillColorPicker is hidden when color_scheme_type is categorical_palette', () => {
expect(
fillColorPickerVisibility(
makeControls(COLOR_SCHEME_TYPES.categorical_palette),
),
).toBe(false);
});
test('fillColorPicker is hidden when color_scheme_type is color_breakpoints', () => {
expect(
fillColorPickerVisibility(
makeControls(COLOR_SCHEME_TYPES.color_breakpoints),
),
).toBe(false);
});
test('fillColorPicker is hidden when color_scheme_type is linear_palette', () => {
expect(
fillColorPickerVisibility(makeControls(COLOR_SCHEME_TYPES.linear_palette)),
).toBe(false);
});
@@ -221,7 +221,10 @@ export const legendPosition = {
clearable: false,
default: 'tr',
choices: [
[null, t('None')],
// A stable string sentinel rather than null: the Select control does not
// reliably round-trip a null-valued option back as null (it can surface
// as undefined/the default), which left "None" unable to hide the legend.
['none', t('None')],
['tl', t('Top left')],
['tr', t('Top right')],
['bl', t('Bottom left')],
@@ -284,6 +287,7 @@ export const fillColorPicker: CustomControlItem = {
default: PRIMARY_COLOR,
renderTrigger: true,
visibility: ({ controls }) =>
!controls.color_scheme_type ||
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.fixed_color),
},
};
@@ -79,11 +79,18 @@ async function runOxlintAndProcess() {
try {
// Run OXC with JSON format
console.log('Running OXC linter...');
const oxlintOutput = execSync('npx oxlint --format json', {
encoding: 'utf8',
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large outputs
stdio: ['pipe', 'pipe', 'ignore'], // Ignore stderr to avoid error output
});
// `oxlint.json` is not the `.oxlintrc.json` oxlint auto-discovers, so the
// config has to be passed explicitly or the run reports oxlint's defaults
// instead of the project's ruleset. Matches the `lint` scripts in
// package.json.
const oxlintOutput = execSync(
'npx oxlint --config oxlint.json --format json',
{
encoding: 'utf8',
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large outputs
stdio: ['pipe', 'pipe', 'ignore'], // Ignore stderr to avoid error output
},
);
const results = JSON.parse(oxlintOutput);
+5 -1
View File
@@ -32,6 +32,10 @@ do
then
echo "po2json --domain superset --format jed1.x $file $filename.json"
po2json --domain superset --format jed1.x --fuzzy $file $filename.json
oxfmt --write $(realpath $filename.json)
# messages.json is gitignored (generated output); oxfmt >=0.62 respects
# .gitignore even for explicitly-passed paths, so it would otherwise
# exit non-zero here with "All matched files may have been excluded by
# ignore rules." Skip the format step for this file instead of failing.
oxfmt --write --no-error-on-unmatched-pattern $(realpath $filename.json)
fi
done
@@ -21,9 +21,15 @@ import { useState } from 'react';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import { logEvent } from 'src/logger/actions';
import { LOG_ACTIONS_DRILL_TO_DETAIL_MODAL_OPENED } from 'src/logger/LogUtils';
import DrillDetailModal from './DrillDetailModal';
jest.mock('./DrillDetailPane', () => () => null);
jest.mock('src/logger/actions', () => ({
...jest.requireActual('src/logger/actions'),
logEvent: jest.fn(() => () => {}),
}));
const mockHistoryPush = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -112,3 +118,32 @@ test('should render "Edit chart" as disabled without can_explore permission', as
});
expect(screen.getByRole('button', { name: 'Edit chart' })).toBeDisabled();
});
test('should log an event when the modal is opened', async () => {
(logEvent as jest.Mock).mockClear();
await renderModal();
expect(logEvent).toHaveBeenCalledWith(
LOG_ACTIONS_DRILL_TO_DETAIL_MODAL_OPENED,
{ slice_id: chartId },
);
});
test('should not log an event while the modal is closed', () => {
(logEvent as jest.Mock).mockClear();
render(
<DrillDetailModal
chartId={chartId}
formData={formData}
initialFilters={[]}
showModal={false}
onHideModal={() => {}}
/>,
{
useRouter: true,
useRedux: true,
initialState: drillToDetailModalState,
},
);
expect(logEvent).not.toHaveBeenCalled();
});
@@ -17,7 +17,7 @@
* under the License.
*/
import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@apache-superset/core/translation';
import {
@@ -26,9 +26,11 @@ import {
} from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { Button, Modal } from '@superset-ui/core/components';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { logEvent } from 'src/logger/actions';
import { LOG_ACTIONS_DRILL_TO_DETAIL_MODAL_OPENED } from 'src/logger/LogUtils';
import { Slice } from 'src/types/Chart';
import { RootState } from 'src/dashboard/types';
import { findPermission } from 'src/utils/findPermission';
@@ -99,6 +101,7 @@ export default function DrillDetailModal({
}: DrillDetailModalProps) {
const theme = useTheme();
const history = useHistory();
const dispatch = useDispatch();
const dashboardPageId = useContext(DashboardPageIdContext);
const { slice_name: chartName } = useSelector(
(state: { sliceEntities: { slices: Record<number, Slice> } }) =>
@@ -108,6 +111,19 @@ export default function DrillDetailModal({
findPermission('can_explore', 'Superset', state.user?.roles),
);
// Unlike DrillByModal, this component stays mounted for the lifetime of the
// chart and is toggled via `showModal`, so the event is gated on the modal
// actually being opened rather than firing on mount.
useEffect(() => {
if (showModal) {
dispatch(
logEvent(LOG_ACTIONS_DRILL_TO_DETAIL_MODAL_OPENED, {
slice_id: chartId,
}),
);
}
}, [dispatch, showModal, chartId]);
const exploreUrl = useMemo(
() => `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${chartId}`,
[chartId, dashboardPageId],
@@ -71,6 +71,7 @@ beforeEach(() => {
jest.restoreAllMocks();
jest.spyOn(Theme, 'fromConfig').mockReturnValue({
SupersetThemeProvider: MockSupersetThemeProvider,
setConfig: jest.fn(),
} as unknown as Theme);
mockNormalizeThemeConfig.mockImplementation(
config => config as ReturnType<typeof normalizeThemeConfig>,
@@ -334,6 +335,68 @@ test('skips the dashboard theme when an SDK theme config override is active', ()
).not.toBeInTheDocument();
});
test('updates the existing Theme in place when the theme changes instead of recreating it', () => {
const setConfig = jest.fn();
(Theme.fromConfig as jest.Mock).mockReturnValue({
SupersetThemeProvider: MockSupersetThemeProvider,
setConfig,
});
const themeA = { token: { colorPrimary: '#aaaaaa' } };
const { rerender } = render(
<CrudThemeProvider
theme={{ id: 1, theme_name: 'A', json_data: JSON.stringify(themeA) }}
>
<div>Dashboard Content</div>
</CrudThemeProvider>,
);
expect(Theme.fromConfig).toHaveBeenCalledTimes(1);
const themeB = { token: { colorPrimary: '#bbbbbb' } };
rerender(
<CrudThemeProvider
theme={{ id: 2, theme_name: 'B', json_data: JSON.stringify(themeB) }}
>
<div>Dashboard Content</div>
</CrudThemeProvider>,
);
// The Theme instance is reused (not recreated) and updated in place, so the
// SupersetThemeProvider identity stays stable and the dashboard subtree is
// not remounted when the applied theme changes.
expect(Theme.fromConfig).toHaveBeenCalledTimes(1);
expect(setConfig).toHaveBeenCalledWith(themeB, expect.anything());
});
test('warns and keeps rendering when the in-place theme update throws', () => {
const setConfig = jest.fn(() => {
throw new Error('setConfig failed');
});
(Theme.fromConfig as jest.Mock).mockReturnValue({
SupersetThemeProvider: MockSupersetThemeProvider,
setConfig,
});
jest.spyOn(logging, 'warn').mockImplementation();
render(
<CrudThemeProvider
theme={{
id: 1,
theme_name: 'Custom Theme',
json_data: JSON.stringify({ token: { colorPrimary: '#ff0000' } }),
}}
>
<div>Dashboard Content</div>
</CrudThemeProvider>,
);
// The stable instance is created, then the in-place update via setConfig
// throws in the layout effect; the error is caught and children still render.
expect(setConfig).toHaveBeenCalled();
expect(logging.warn).toHaveBeenCalled();
expect(screen.getByText('Dashboard Content')).toBeInTheDocument();
});
test('applies the dashboard theme when no SDK theme config override is active', () => {
const themeConfig = { token: { colorPrimary: '#ff0000' } };
render(
@@ -16,7 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useContext, useEffect, useMemo } from 'react';
import {
ReactNode,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { logging } from '@apache-superset/core/utils';
import {
Theme,
@@ -33,28 +40,24 @@ interface CrudThemeProviderProps {
}
/**
* CrudThemeProvider applies a dashboard-specific theme using theme data
* from the dashboard API response. Merges with the system's base theme
* (light or dark) and loads custom fonts. Falls back to the global theme
* if the theme data is missing or invalid.
* Applies a dashboard-specific theme from the dashboard API response, merged
* over the system's base theme (light or dark), and loads custom fonts. Falls
* back to the global theme when the theme data is missing or invalid.
*
* A single, stable Theme instance is updated in place instead of recreated, so
* the SupersetThemeProvider identity stays constant and the dashboard subtree
* is not remounted when the applied theme changes.
*/
export default function CrudThemeProvider({
children,
theme,
}: CrudThemeProviderProps) {
// An explicit theme config override (e.g. supplied via the Embedded SDK)
// applies on the global theme controller and must win over the
// dashboard-level theme. When such an override is active, skip the
// dashboard theme so the override is not shadowed by this nested provider.
const themeContext = useContext(ThemeContext);
const hasThemeConfigOverride = themeContext?.hasThemeConfigOverride ?? false;
const { dashboardTheme, fontUrls } = useMemo(() => {
// When an SDK override is active it fully owns theming, so skip parsing the
// dashboard theme entirely. This also prevents the font-injection effect
// below from loading dashboard fonts the override does not use.
const parsedTheme = useMemo(() => {
if (hasThemeConfigOverride || !theme?.json_data) {
return { dashboardTheme: null, fontUrls: undefined };
return null;
}
try {
const themeConfig = JSON.parse(theme.json_data);
@@ -64,26 +67,53 @@ export default function CrudThemeProvider({
common: { theme: bootstrapTheme },
} = getBootstrapData();
const baseTheme = isDark ? bootstrapTheme.dark : bootstrapTheme.default;
const createdTheme = Theme.fromConfig(
normalizedConfig,
baseTheme || undefined,
);
const rawUrls = themeConfig?.token?.fontUrls;
const urls = Array.isArray(rawUrls) ? (rawUrls as string[]) : undefined;
return { dashboardTheme: createdTheme, fontUrls: urls };
const fontUrls = Array.isArray(rawUrls)
? (rawUrls as string[])
: undefined;
return { normalizedConfig, baseTheme: baseTheme || undefined, fontUrls };
} catch (error) {
logging.warn('Failed to load dashboard theme:', error);
return { dashboardTheme: null, fontUrls: undefined };
return null;
}
}, [theme?.json_data, hasThemeConfigOverride]);
// Create the stable instance once; update it in place on later changes.
const dashboardThemeRef = useRef<Theme | null>(null);
if (parsedTheme && !dashboardThemeRef.current) {
try {
dashboardThemeRef.current = Theme.fromConfig(
parsedTheme.normalizedConfig,
parsedTheme.baseTheme,
);
} catch (error) {
logging.warn('Failed to load dashboard theme:', error);
}
}
useLayoutEffect(() => {
if (parsedTheme && dashboardThemeRef.current) {
try {
dashboardThemeRef.current.setConfig(
parsedTheme.normalizedConfig,
parsedTheme.baseTheme,
);
} catch (error) {
logging.warn('Failed to load dashboard theme:', error);
}
}
}, [parsedTheme]);
useEffect(() => {
if (hasThemeConfigOverride || !dashboardTheme || !fontUrls?.length) {
if (
!parsedTheme ||
!dashboardThemeRef.current ||
!parsedTheme.fontUrls?.length
) {
return undefined;
}
// JSON.stringify provides safe escaping to prevent CSS injection
const css = fontUrls
// JSON.stringify escapes the URL to prevent CSS injection.
const css = parsedTheme.fontUrls
.map((url: string) => `@import url(${JSON.stringify(url)});`)
.join('\n');
const style = document.createElement('style');
@@ -94,15 +124,13 @@ export default function CrudThemeProvider({
return () => {
style.remove();
};
}, [dashboardTheme, fontUrls, hasThemeConfigOverride]);
}, [parsedTheme]);
if (!dashboardTheme || hasThemeConfigOverride) {
if (!parsedTheme || !dashboardThemeRef.current) {
return <>{children}</>;
}
return (
<dashboardTheme.SupersetThemeProvider>
{children}
</dashboardTheme.SupersetThemeProvider>
);
const DashboardThemeProvider =
dashboardThemeRef.current.SupersetThemeProvider;
return <DashboardThemeProvider>{children}</DashboardThemeProvider>;
}
@@ -123,6 +123,7 @@ const pluginApi = makeApi<{}, { result: Plugin[] }>({
const sharedModules = {
react: () => import('react'),
// oxlint-disable-next-line no-restricted-imports
lodash: () => import('lodash'),
'react-dom': () => import('react-dom'),
'@superset-ui/chart-controls': () => import('@superset-ui/chart-controls'),
@@ -298,6 +298,80 @@ describe('dashboardState actions', () => {
{ event: 'dashboard_properties_changed' },
);
});
// Regression for the reserved-URL-character slug redirect. When the
// submitted slug starts with a reserved character (e.g. `?` or `/`), the
// backend sanitizes it out of the persisted slug
// (BaseDashboardSchema.post_load strips `[^\w\-]`). The post-save redirect
// must therefore be built from the sanitized slug returned in the PUT
// response, not the raw slug the user submitted, or the first render lands
// on a malformed/blank URL (a later reload works because it reads the
// stored, sanitized slug).
test('redirects using the sanitized slug from the PUT response, not the raw submitted slug', async () => {
const updatedId = 778;
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: true },
});
mockNavigateWithState.mockClear();
putStub.mockRestore();
putStub = jest.spyOn(SupersetClient, 'put').mockResolvedValue({
json: {
result: { ...mockDashboardData, id: updatedId, slug: 'test' },
last_modified_time: 0,
},
} as any);
const thunk = saveDashboardRequest(
{ ...newDashboardData, slug: '?test' },
updatedId,
SAVE_TYPE_OVERWRITE,
);
await thunk(dispatch, getState);
await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
await waitFor(() => expect(mockNavigateWithState).toHaveBeenCalled());
expect(mockNavigateWithState).toHaveBeenCalledWith('/dashboard/test/', {
event: 'dashboard_properties_changed',
});
});
// Regression companion: when the PUT response carries no usable slug
// (e.g. a slug composed solely of reserved characters, which
// BaseDashboardSchema.post_load sanitizes down to an empty string rather
// than null), the redirect must fall back to the dashboard id rather
// than emitting `/dashboard//` or echoing the raw submitted slug.
test('redirects using the id when the PUT response slug is empty', async () => {
const updatedId = 779;
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: true },
});
mockNavigateWithState.mockClear();
putStub.mockRestore();
putStub = jest.spyOn(SupersetClient, 'put').mockResolvedValue({
json: {
result: { ...mockDashboardData, id: updatedId, slug: '' },
last_modified_time: 0,
},
} as any);
const thunk = saveDashboardRequest(
{ ...newDashboardData, slug: '?' },
updatedId,
SAVE_TYPE_OVERWRITE,
);
await thunk(dispatch, getState);
await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
await waitFor(() => expect(mockNavigateWithState).toHaveBeenCalled());
expect(mockNavigateWithState).toHaveBeenCalledWith(
`/dashboard/${updatedId}/`,
{ event: 'dashboard_properties_changed' },
);
});
});
test('fetchCharts returns a Promise that resolves after all refreshes', async () => {
@@ -630,8 +630,13 @@ export function saveDashboardRequest(
dispatch(saveDashboardRequestSuccess(lastModifiedTime));
}
dispatch(saveDashboardFinished());
// redirect to the new slug or id
navigateWithState(`/dashboard/${slug || id}/`, {
// Redirect using the slug from the update response, not the raw
// submitted slug. The backend sanitizes reserved URL characters out of
// the slug (BaseDashboardSchema.post_load strips `[^\w\-]`), so the raw
// slug can differ from what was persisted and would build a malformed
// URL on first render. Fall back to the id when the response has no slug.
const updatedSlug = updatedDashboard.slug as string | null | undefined;
navigateWithState(`/dashboard/${updatedSlug || id}/`, {
event: 'dashboard_properties_changed',
});
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render } from 'spec/helpers/testing-library';
import { act, fireEvent, render } from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core';
import * as redux from 'redux';
@@ -655,3 +655,181 @@ test('should pass chartStackTrace to ChartContainer so dashboard chart errors st
stackTrace,
);
});
function installResizeObserverMock() {
const observeMock = jest.fn();
const disconnectMock = jest.fn();
let observerCallback: ResizeObserverCallback | undefined;
const mockResizeObserver = jest.fn().mockImplementation(callback => {
observerCallback = callback;
return { observe: observeMock, disconnect: disconnectMock };
});
global.ResizeObserver = mockResizeObserver as any;
const getComputedStyleSpy = jest
.spyOn(window, 'getComputedStyle')
.mockReturnValue({
getPropertyValue: () => '',
} as unknown as CSSStyleDeclaration);
return {
observeMock,
getObserverCallback: () => observerCallback,
restore: () => {
delete (global as any).ResizeObserver;
getComputedStyleSpy.mockRestore();
},
};
}
test('A chart description configured to start expanded is visible after initial render', () => {
const { observeMock, restore } = installResizeObserverMock();
try {
const { container } = setup(
{},
{
dashboardState: {
...defaultState.dashboardState,
expandedSlices: { [queryId]: true },
},
},
);
expect(container.querySelector('.slice_description')).toBeInTheDocument();
expect(observeMock).toHaveBeenCalled();
} finally {
restore();
}
});
test('The description height is correctly updated after asynchronous markdown rendering completes', () => {
const { getObserverCallback, restore } = installResizeObserverMock();
try {
const { container } = setup(
{ height: 300 },
{
charts: {
...defaultState.charts,
[queryId]: {
...defaultState.charts[queryId],
chartStatus: 'loading',
},
},
dashboardState: {
...defaultState.dashboardState,
expandedSlices: { [queryId]: true },
},
},
);
const descriptionEl = container.querySelector(
'.slice_description',
) as HTMLElement;
expect(descriptionEl).toBeInTheDocument();
const observerCallback = getObserverCallback();
if (observerCallback) {
Object.defineProperty(descriptionEl, 'offsetHeight', {
value: 100,
configurable: true,
});
act(() => {
observerCallback(
[{ target: descriptionEl } as unknown as ResizeObserverEntry],
{} as ResizeObserver,
);
});
}
const chartHeight = parseInt(
container.querySelector<HTMLDivElement>('.dashboard-chart > div[style]')!
.style.height,
10,
);
expect(chartHeight).toBe(300 - 22 - 100);
} finally {
restore();
}
});
test('The ResizeObserver callback updates the measured height', () => {
const { getObserverCallback, restore } = installResizeObserverMock();
try {
const { container } = setup(
{ height: 400 },
{
charts: {
...defaultState.charts,
[queryId]: {
...defaultState.charts[queryId],
chartStatus: 'loading',
},
},
dashboardState: {
...defaultState.dashboardState,
expandedSlices: { [queryId]: true },
},
},
);
const descriptionEl = container.querySelector(
'.slice_description',
) as HTMLElement;
expect(descriptionEl).toBeInTheDocument();
const observerCallback = getObserverCallback();
if (observerCallback) {
Object.defineProperty(descriptionEl, 'offsetHeight', {
value: 200,
configurable: true,
});
act(() => {
observerCallback(
[{ target: descriptionEl } as unknown as ResizeObserverEntry],
{} as ResizeObserver,
);
});
}
const chartHeight = parseInt(
container.querySelector<HTMLDivElement>('.dashboard-chart > div[style]')!
.style.height,
10,
);
expect(chartHeight).toBe(400 - 22 - 200);
} finally {
restore();
}
});
test('Existing expand/collapse behavior continues to work', () => {
const { restore } = installResizeObserverMock();
try {
const collapsedSetup = setup(
{},
{
dashboardState: {
...defaultState.dashboardState,
expandedSlices: { [queryId]: false },
},
},
);
expect(
collapsedSetup.container.querySelector('.slice_description'),
).not.toBeInTheDocument();
const expandedSetup = setup(
{},
{
dashboardState: {
...defaultState.dashboardState,
expandedSlices: { [queryId]: true },
},
},
);
expect(
expandedSetup.container.querySelector('.slice_description'),
).toBeInTheDocument();
} finally {
restore();
}
});
@@ -323,11 +323,39 @@ const Chart = (props: ChartProps) => {
);
useLayoutEffect(() => {
if (isExpanded && descriptionRef.current) {
setDescriptionHeight(descriptionRef.current.offsetHeight);
} else {
if (!isExpanded || !descriptionRef.current) {
setDescriptionHeight(0);
return undefined;
}
let isDescriptionHeightSet = false;
const initialHeight = descriptionRef.current.offsetHeight;
if (initialHeight > 0) {
setDescriptionHeight(initialHeight);
isDescriptionHeightSet = true;
}
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
if (entry.target === descriptionRef.current) {
const height = (entry.target as HTMLElement).offsetHeight;
if (height > 0 || !isDescriptionHeightSet) {
setDescriptionHeight(height);
isDescriptionHeightSet = true;
}
}
}
});
observer.observe(descriptionRef.current);
return () => {
observer.disconnect();
};
}
return undefined;
}, [isExpanded]);
useEffect(
@@ -18,6 +18,7 @@
*/
import React from 'react';
import {
act,
fireEvent,
render,
RenderResult,
@@ -25,6 +26,10 @@ import {
} from 'spec/helpers/testing-library';
import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
import {
FORCE_IN_VIEW_EVENT,
RESTORE_VIRTUALIZATION_EVENT,
} from 'src/dashboard/constants';
import { getMockStore } from 'spec/fixtures/mockStore';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
import { initialState } from 'src/SqlLab/fixtures';
@@ -337,4 +342,58 @@ describe('visibility handling for intersection observers', () => {
expect(() => callback([nonIntersectingEntry])).not.toThrow();
expect(callback([nonIntersectingEntry])).toBe(false);
});
test('force-in-view event with no detail disconnects the observers for every row', () => {
setup({ isComponentVisible: true });
act(() => {
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
});
expect(mockDisconnect).toHaveBeenCalled();
});
test('force-in-view event scoped to other rowIds does not disconnect this row', () => {
setup({ isComponentVisible: true });
act(() => {
window.dispatchEvent(
new CustomEvent(FORCE_IN_VIEW_EVENT, {
detail: { rowIds: ['SOME_OTHER_ROW_ID'] },
}),
);
});
expect(mockDisconnect).not.toHaveBeenCalled();
});
test('force-in-view event scoped to this rowId disconnects the observers', () => {
setup({ isComponentVisible: true });
act(() => {
window.dispatchEvent(
new CustomEvent(FORCE_IN_VIEW_EVENT, {
detail: { rowIds: [props.id] },
}),
);
});
expect(mockDisconnect).toHaveBeenCalled();
});
test('restore-virtualization event re-observes after a force-in-view', () => {
setup({ isComponentVisible: true });
expect(mockObserve).toHaveBeenCalledTimes(2);
act(() => {
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
});
act(() => {
window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
});
// The initial mount observes twice (enabler + disabler); restoring
// after a force-in-view re-observes both again.
expect(mockObserve).toHaveBeenCalledTimes(4);
});
});

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