Compare commits

..

109 Commits

Author SHA1 Message Date
Evan
e8c7d6656a fix(explore): allow chart owners to overwrite when owners are objects
The Save chart modal gates "Save (Overwrite)" on canOverwriteSlice(),
which checked slice.owners.includes(user.userId). The Slice type declares
owners as { id: number }[], and the Explore bootstrap can hydrate owners in
object form, so includes() against a numeric userId never matched. A chart
owner who wasn't also an admin was therefore forced into "Save as...",
which is the path that produces the duplicate/stale-chart behavior reported
in the issue.

Normalize owner identity to the numeric id before comparing so owners are
recognized whether they arrive as plain ids or as objects.

Fixes #38911

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:19:01 -07:00
David
90fe1f5b7c fix(auth): redirect to login on failure to access dashboard permalink (#40769) 2026-06-28 17:49:25 -07:00
Abdul Rehman
a529945d3b fix(reports): commit permalink before Playwright navigation (#41051) 2026-06-28 17:48:44 -07:00
jesperct
0c12114ea9 fix(explore): stop metric edits from bleeding across metrics with duplicate optionNames (#41208)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:46:13 -07:00
Hans Yu
6eb51105d0 refactor: update SQLAlchemy case() syntax to 2.0 (#40275) 2026-06-28 12:46:02 -07:00
Đỗ Trọng Hải
b2a2698898 chore: unify Node version (#41500) 2026-06-28 12:22:36 -07:00
dependabot[bot]
efed2b09aa chore(deps-dev): bump @types/node from 25.9.3 to 26.0.0 in /superset-frontend (#41451)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 12:15:29 -07:00
dependabot[bot]
c3a11e3170 chore(deps): bump sqlalchemy-utils from 0.42.0 to 0.42.1 (#41478)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-06-28 12:15:14 -07:00
dependabot[bot]
98d61c5cf8 chore(deps-dev): bump duckdb from 1.5.3 to 1.5.4 (#41480)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-06-28 12:14:59 -07:00
dependabot[bot]
7d5e01b6cf chore(deps-dev): update taospy requirement from >=2.7.21 to >=2.8.9 (#41481)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-06-28 12:14:49 -07:00
qlimenoque
a70d055669 feat(helm): use env redis_driver, render additional deployment pod spec (#26040) 2026-06-27 22:44:14 -07:00
Evan Rusackas
670f25e5f4 chore(lint): convert explore controls, SqlLab, and misc components to function components (#39461)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-06-27 22:41:53 -07:00
Evan Rusackas
7fc1113c31 fix(telemetry): use Scarf static pixel instead of gateway redirect (#41129)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-27 22:41:17 -07:00
dependabot[bot]
ccbd284245 chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#41447)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 12:21:53 +07:00
Evan Rusackas
d0268442f8 chore(ci): correct actions/cache pin comment to actual version v5.0.5 (#41482)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:41:55 +07:00
ʈᵃᵢ
25c9f3510a test(mcp): set embedded on update_dashboard test mock (#41495) 2026-06-28 11:19:01 +07:00
Đỗ Trọng Hải
b8fd2e9725 feat(websocket,embedded-sdk): replace Jest with modern Vitest (#38308)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-06-28 11:12:37 +07:00
Evan Rusackas
78dd400ca4 chore(ci): correct actions/cache version comment to match pinned SHA (#41483)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:56:33 +07:00
Evan Rusackas
7587d0778a chore(ci): correct actions/cache version comment to v5.0.5 (#41484)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:56:08 +07:00
Elizabeth Thompson
97cb002f46 fix(a11y): propagate tooltip string as aria-label on IconTooltip button (#41493) 2026-06-27 15:01:46 -07:00
Elizabeth Thompson
5ec0931840 fix(pandas_postprocessing): pass string operator names to GroupBy.agg to avoid FutureWarning (#41025) 2026-06-27 15:01:43 -07:00
Elizabeth Thompson
3eb9185521 fix(viz): use series_limit/series_limit_metric in query_obj dict (#41002)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:01:40 -07:00
Shaitan
cd8ac41d16 fix(datasource): validate expressions through the shared adhoc-expression checks (#41427)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:47:59 +01:00
Evan Rusackas
21999bb772 fix(i18n): repair corrupted Romanian catalog so it parses again (#41467)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-27 09:13:39 -04:00
innovark
0a18779280 fix(echarts): format mixed timeseries value labels by assigned axis (#40420)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-27 01:27:41 -07:00
Krishna Chaitanya
a147079043 fix(bigquery): backslash-escape apostrophes in filter values (#38835)
BigQuery rejects filter values containing apostrophes (e.g. O'Brien): the
sqlalchemy-bigquery dialect renders string literals via repr(), which switches
to double-quote delimiters that BigQuery parses as identifiers, causing a
syntax error.

Monkey-patch the dialect's colspecs with a TypeDecorator whose literal_processor
emits single-quoted literals using backslash escaping ('O\'Brien'). Doubled
single quotes ('O''Brien') are NOT valid in BigQuery (parsed as concatenated
literals). Control characters are emitted as named escapes with a \xhh fallback,
since BigQuery forbids literal control chars in quoted strings. Follows the
existing Databricks dialect pattern.

Fixes #35857
2026-06-27 00:55:47 -07:00
Abdul Rehman
ebb32de625 fix(cachekey): use data_cache for chart query result invalidation (#40493) 2026-06-26 18:01:14 -07:00
Onur Taşhan
1280eaee18 fix(mcp): include embedded_uuid in get_dashboard_info response (#41195)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:00:10 -07:00
jesperct
15626a047c fix(sqllab): quote autocomplete table names that need it (#41199) 2026-06-26 17:58:05 -07:00
madhushreeag
dc64716c61 fix(echarts): bring annotations in front and prevent tooltip from covering annotation labels (#41174)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-26 16:21:41 -07:00
Evan Rusackas
6f12dbf0e1 feat(api): log rejected related/distinct field access as security events (#41306)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-26 15:45:36 -07:00
Abdelghani Belgaid
022f66a694 fix(country-map): update Morocco GeoJSON boundaries (#41021) 2026-06-26 15:11:16 -07:00
Evan Rusackas
ac9bf26751 chore(deps): bump vulnerable transitive deps across lockfiles (#41307)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:45:56 -07:00
Luiz Otavio
834ccf2613 fix(chart): chart description not showing (#41453) 2026-06-26 12:38:30 -07:00
Joe Li
98d0ccd7a7 fix(reports): reliability fixes for alert/report execution (#41177)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:09:12 -07:00
Evan Rusackas
8aacb6f793 fix(async): derive async channel from guest token for embedded RLS queries (closes #31492) (#41397)
Co-authored-by: Devin AI <devin-ai-integration[bot]@users.noreply.github.com>
2026-06-26 12:09:06 -07:00
Evan Rusackas
eaaab61493 chore(ci): correct setup-python pin version comment to v6.2.0 (#41383)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:08:46 -07:00
Evan Rusackas
068a709c14 fix(config): expose build details (git SHA/build number) to admins only (#41301)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-26 12:08:04 -07:00
Evan Rusackas
71c8e2f69d fix(config): refuse to start with an empty SECRET_KEY (#41299)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-26 12:07:17 -07:00
Evan Rusackas
bfa6cfac85 fix(database): mask SSH tunnel credentials explicitly on read paths (#41293)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-26 12:07:02 -07:00
Evan Rusackas
c03cdade39 chore(deck.gl): remove leftover debug className from Legend (#41165)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-26 12:06:47 -07:00
Evan Rusackas
0efcbcdd81 test(security): regression coverage for #36130 FAB permission view templates (#41130)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:06:27 -07:00
Kasia
11d7f7fb87 fix: convert ALL CAPS button labels to sentence case (#40435) 2026-06-26 12:05:13 -07:00
Elizabeth Thompson
c87fdfc18f fix(i18n): defer plugin init and menu render until language pack is ready (#40729)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:48:18 -07:00
madhushreeag
667005638a fix(dropdown): clear search input after selection in all multi-select fields (#41074)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-26 10:46:52 -07:00
Joe Li
f10315f8fc test(databases): migrate database modal Cypress tests to RTL (#41436)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:58:32 -07:00
Amin Ghadersohi
a5dbb394e5 fix(thumbnails): add deduplication to dashboard thumbnail Celery tasks (#38576)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:41:02 -04:00
Gabriel Torres Ruiz
f49db9e536 fix(dashboard): restore page scrolling (#41439) 2026-06-26 12:54:19 -03:00
dependabot[bot]
84e07df735 chore(deps): bump react-draggable from 4.6.0 to 4.7.0 in /superset-frontend (#41446)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 08:31:37 -07:00
dependabot[bot]
b8f3918bcf chore(deps-dev): bump react-resizable from 4.0.1 to 4.0.2 in /superset-frontend (#41448)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 08:31:23 -07:00
dependabot[bot]
ee43d8869f chore(deps): bump nanoid from 5.1.11 to 5.1.14 in /superset-frontend (#41450)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 08:31:11 -07:00
Evan Rusackas
01a0c66c79 fix(sunburst): make "Show Null Values" non-breaking and cover all layers (#41442)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-26 08:30:09 -07:00
Brett Smith
35365d639d fix(deckgl): render legend swatch as a coloured box, not an emoji glyph (#40784)
Signed-off-by: Brett Smith <brett@pukekos.co.nz>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Damian Pendrak <dpendrak@gmail.com>
2026-06-26 10:07:29 +02:00
Michael Gerber
7e17c70cba fix: Filter null child names in treeBuilder utility (#31477)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 22:03:45 -07:00
SkinnyPigeon
0d43c2c12c feat(reports): trigger alerts (#41336) 2026-06-25 22:01:39 -07:00
Evan Rusackas
7410ff73c0 ci: schedule a weekly Docker image rebuild against the latest release (#40426)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 17:15:31 -07:00
Debabrata Saha
f08f068240 fix(sqllab): replace native prompt with modal for tab rename (#41329)
Signed-off-by: debabsah <debasaha.uw@gmail.com>
2026-06-25 17:15:07 -07:00
Greg Neighbors
2b09b6bc1d feat(mcp): list_charts accepts dashboards filter (#40397)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
2026-06-25 17:14:11 -07:00
Özgür YÜKSEL
d763255e15 chore(i18n): update Turkish translations messages.po (#39064)
Co-authored-by: Özgür YÜKSEL <o.yuksel@gardiyan.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 17:11:40 -07:00
Evan Rusackas
8fed514e79 fix(dashboard): keep pasted filter values outside the loaded page (#41136)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 15:33:57 -07:00
Evan Rusackas
c94bc7178f fix(world-map): rely on built-in highlightOnHover to reset hover highlight (#41158)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 15:33:46 -07:00
Evan Rusackas
95ecdd3753 fix(menu): highlight active nav tab in non-English locales (#41183)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 15:33:30 -07:00
Evan Rusackas
aac02ab679 fix(deck.gl): use interval notation for Polygon legend bucket labels (#41400)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:23:34 -07:00
madhushreeag
de01fe2ff0 fix(chart-controls): fix RadioButtonControl crash on empty options and false values (#41170)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-25 12:02:58 -07:00
Beto Dealmeida
9965c05699 fix(semantic layers): small fixes (#40474) 2026-06-25 14:59:49 -04:00
Greg Neighbors
d8bcc66472 feat(mcp): dashboard layout, theme, and CSS control + update_dashboard tool (#40399)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-MacBook-Air-2.local>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
2026-06-25 10:41:07 -07:00
Evan Rusackas
4b9b8187b3 fix(config): make Swagger UI opt-in (off by default) (#41300)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 10:34:28 -07:00
Evan Rusackas
83f7dc9d5b chore(codeowners): add translation maintainers (#41429)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 10:09:16 -07:00
Elizabeth Thompson
baca76ebe0 fix(slack): fix indented triple-quoted string in v1 API deprecation warning (#41393) 2026-06-25 09:54:33 -07:00
Mehmet Salih Yavuz
9a11c15a33 feat(explore): add full-range option for time-shift comparison (#41334) 2026-06-25 18:30:33 +03:00
Michael S. Molina
a90c8e0347 feat(extensions): add Chat contribution type (SIP-214) (#41205)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:57:30 -03:00
dependabot[bot]
fe2424ec14 chore(deps): bump mapbox-gl from 3.24.1 to 3.25.0 in /superset-frontend (#41409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:48 -07:00
dependabot[bot]
b4f43bd7e0 chore(deps): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /docs (#41405)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:45 -07:00
dependabot[bot]
2b25345ed9 chore(deps-dev): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /superset-frontend (#41413)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:41 -07:00
Evan Rusackas
e0f3f93cd4 fix(mcp): require MCP_JWT_AUDIENCE when MCP JWT auth is enabled (#41292)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:53:36 -07:00
Evan Rusackas
0667ba6097 chore(deps): bump dompurify and http-proxy-middleware (security) (#41289)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:56 -07:00
Evan Rusackas
81f7e42f4e fix(rls): preserve tables/roles on partial RLS rule updates (#41294)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:47 -07:00
Evan Rusackas
0fd244b5c6 fix(security): reject unknown fields on guest-token RLS rules (#41217)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 16:16:43 -07:00
Evan Rusackas
1f16d10cbf chore(deps): bump pyjwt to 2.13.0 (CVE-2026-48526) (#41288)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:40 -07:00
Evan Rusackas
4f4663418f fix(tests): stabilize update_chart MCP test failing on previous-Python CI leg (#41310)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:16:14 -07:00
Evan Rusackas
4519a5c52d fix(safe-markdown): do not mutate the shared sanitization schema (#41298)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:06 -07:00
Evan Rusackas
da9fbadaf6 fix(logout): purge the namespaced Cache API store on logout (#41303)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:50 -07:00
Evan Rusackas
f40abbbefd fix(mcp): fail closed when the JWT verifier has no pinned algorithm (#41296)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:42 -07:00
Evan Rusackas
6166af3c3c fix(mcp): reject non-finite JWT exp instead of 500ing on int() overflow (#41394)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:29 -07:00
Evan Rusackas
076d8c1508 docs(security): add a secrets register and rotation schedule (#41308)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:17 -07:00
Elizabeth Thompson
518cadd907 fix(mcp_service): reduce deprecated authlib.jose.errors imports (#41248) 2026-06-24 15:01:58 -07:00
JUST.in DO IT
b955c90de4 fix(sqllab): Invalid multi sorting state in table header (#40680) 2026-06-25 06:43:02 +09:00
Evan Rusackas
7363774869 fix(theming): deep-merge partial THEME_DEFAULT overrides with built-in defaults (#41347)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 13:27:32 -07:00
Vansh Gilhotra
6f12d17313 fix(charts): show user-friendly error for HTTP 413 payload too large (#37131)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 11:21:59 -07:00
abhyudaytomar
09c7ba14df fix(export): sanitize control characters in titles to prevent export failures (#39294)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:46 -07:00
Elizabeth Thompson
3ec4bd23c4 fix(deps): restore np.nan in offset_metrics_df empty branch (#41267)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:49:38 -07:00
yousoph
f6ce105450 fix(pandas-postprocessing): handle prophet errors and validate minimum data points for forecast (#41180)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:44:23 -07:00
Evan Rusackas
7bb4e82a82 fix(dashboard): Remove 308 redirect when creating new dashboards (#41343)
Co-authored-by: ericsong <eric.song@example.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:31:31 -07:00
Kamil Gabryjelski
2d78a8733c fix(plugin-chart-ag-grid-table): show correct percent-metric totals in summary row (#41247)
Signed-off-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
2026-06-24 19:21:00 +02:00
Evan Rusackas
3261d10270 chore(frontend): enforce TypeScript-only source files (#41385)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 05:54:37 -07:00
Shlummie
a57b5f6078 fix(deckgl): show dashboard filter badges for multi-layer charts (#40003)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:14:25 -07:00
MelikHajlawi
d1b523b97f docs: fix placeholder text in @superset-ui/core README (#40002)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:07:24 -07:00
Shashwati Bhattacharyaa
91188a0302 fix(config): Wire LOGO_TARGET_PATH and document custom spinner usage (#36951)
Co-authored-by: Shashwati <shashwatibhattacaharya21.2@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-06-24 01:56:15 -07:00
MUHAMMED SINAN D
ac234d0fb2 fix(dashboard): prevent x-axis clipping when toggling chart description (#38307) 2026-06-24 01:54:43 -07:00
felipegr0ssi
8eb753eab2 fix(dashboard): keep native filter dropdown from covering input (#40032)
Co-authored-by: feehgrossi <felipe.leite@sptech.school>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:44 -07:00
abhyudaytomar
779fa13679 fix(security): prevent duplicate items in permissions dropdown on scroll (#39292)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:27 -07:00
Greg Neighbors
caf81e71d2 feat(mcp): add typed Pydantic response schemas to generate_explore_link tool (#39900)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:53:08 -07:00
Eddy
1b8c6d109d feat: added deterministic field generation to dashboard export (#36339)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 01:41:44 -07:00
Viktor Högberg
eb60e5477b fix(radar): correct legend margin control in the radar chart (#39414) 2026-06-24 01:41:24 -07:00
Puneet Dixit
7b9bcdd951 fix(bigquery): preserve catalog in partition metadata lookup (#40200)
Co-authored-by: Puneet Dixit <rvit23bcs086.rvitm@rvei.edu.in>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 01:41:06 -07:00
ruhz3
d9d395bde1 fix(helm): remove unused SQLALCHEMY_TRACK_MODIFICATIONS setting (#37259) 2026-06-24 01:28:30 -07:00
Jay Masiwal
584d41759b refactor: migrate test files from nested describe blocks and remove stale lint ignores (#39202)
Co-authored-by: Joe Li <joe@preset.io>
2026-06-24 01:19:15 -07:00
abdullah reveha
8f22b71898 feat(chart): enable cross-filter on x-axis labels for bar, line, area and scatter charts (#41111)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-06-24 01:17:29 -07:00
omkarhall
1ea3584dcb fix(chart): added Big Number chart support for MAX metric with VARCHAR column (#41182) 2026-06-24 01:11:13 -07:00
353 changed files with 29947 additions and 34962 deletions

2
.github/CODEOWNERS vendored
View File

@@ -38,7 +38,7 @@
# Notify translation maintainers of changes to translations
/superset/translations/ @sfirke @rusackas
/superset/translations/ @sfirke @rusackas @villebro @sadpandajoe @hainenber
# Notify PMC members of changes to extension-related files

View File

@@ -42,7 +42,7 @@ runs:
fi
echo "python-version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Python ${{ steps.set-python-version.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ steps.set-python-version.outputs.python-version }}
cache: ${{ inputs.cache }}

View File

@@ -63,7 +63,7 @@ jobs:
yarn install --immutable
- name: Cache pre-commit environments
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/pre-commit
key: pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}

View File

@@ -56,7 +56,7 @@ jobs:
- name: Cache npm
if: env.HAS_TAGS
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS
key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}
@@ -70,7 +70,7 @@ jobs:
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Cache npm
if: env.HAS_TAGS
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: npm-cache # use this to check for `cache-hit` (`steps.npm-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.npm-cache-dir-path.outputs.dir }}

View File

@@ -0,0 +1,177 @@
name: Scheduled Docker image refresh
# Re-runs the Docker image build against the latest published release on a
# weekly cadence. The code being built doesn't change — but the base image
# layers (python:*-slim-trixie and its OS packages) DO get upstream
# security patches between Superset releases, and those patches don't
# reach our published images unless we rebuild.
#
# Without this workflow, `apache/superset:<latest>` lags behind upstream
# Debian/Python base patches by whatever interval falls between Superset
# releases (typically 36 weeks). With it, the lag drops to at most one
# week regardless of release cadence.
#
# This is a security-hygiene cron, not a release. It overwrites the
# existing tags for the most recent release (e.g. `apache/superset:5.0.0`
# and `apache/superset:latest`) with bit-for-bit-equivalent contents
# layered on a refreshed base. Image digests change; everything users
# actually pin against (image content, code, deps) does not.
on:
schedule:
# Mondays at 06:00 UTC — gives the weekend for upstream patches to
# settle and surfaces failures at the start of the work week so a
# human can react.
- cron: "0 6 * * 1"
# Manual trigger so operators can force a refresh on demand (e.g.
# immediately after a high-severity base-image CVE drops).
workflow_dispatch: {}
permissions:
contents: read
# Serialize with itself and with the release publisher (tag-release.yml) —
# both push to the same Docker Hub tags, so a race could end with stale
# layers winning. Both workflows must declare this group for the lock to work.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
latest-release: ${{ steps.latest.outputs.tag }}
force-latest: ${{ steps.latest.outputs.force-latest }}
steps:
- name: Check for Docker Hub secrets
id: check
shell: bash
run: |
if [ -n "${DOCKERHUB_USER}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
env:
DOCKERHUB_USER: ${{ (secrets.DOCKERHUB_USER != '' && secrets.DOCKERHUB_TOKEN != '') || '' }}
- name: Look up latest published release
id: latest
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
run: |
# `releases/latest` returns the latest non-prerelease, non-draft
# release — which is exactly what `apache/superset:latest`
# should reflect.
TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq .tag_name)
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
echo "::error::Could not determine latest release tag"
exit 1
fi
echo "Latest release: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# Only move `:latest` when the release flagged "latest" is also the
# highest semver release. This guards against a mis-click leaving an
# older maintenance release (e.g. a 5.x patch shipped after 6.0 GA)
# marked latest, which would otherwise roll `:latest` back a major
# version on the next cron run. If it isn't the newest, we still
# refresh that release's own version tag but leave `:latest` alone.
HIGHEST=$(gh api --paginate "repos/${REPOSITORY}/releases" \
--jq '.[] | select(.draft|not) | select(.prerelease|not) | .tag_name' \
| sed 's/^v//' | sort -V | tail -n1)
if [ "${TAG#v}" = "$HIGHEST" ]; then
echo "force-latest=1" >> "$GITHUB_OUTPUT"
else
echo "::warning::Latest-flagged release $TAG is not the highest semver ($HIGHEST); refreshing its version tag but leaving :latest untouched"
fi
docker-rebuild:
needs: config
if: needs.config.outputs.has-secrets == '1'
name: docker-rebuild
runs-on: ubuntu-24.04
strategy:
# Mirror the same matrix the release publisher uses so every variant
# operators consume from Docker Hub gets the refreshed base.
matrix:
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
fail-fast: false
steps:
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.config.outputs.latest-release }}
fetch-depth: 0
persist-credentials: false
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
install-docker-compose: "false"
build: "true"
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Rebuild and push
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_PRESET: ${{ matrix.build_preset }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
FORCE_LATEST_FLAG: ${{ needs.config.outputs.force-latest == '1' && '--force-latest' || '' }}
run: |
# Reuses the same supersetbot invocation as the release
# publisher (`tag-release.yml`), so the resulting tags are
# identical to what a manual release dispatch would produce —
# just with a freshly-pulled base image layer underneath.
# `--force-latest` is only passed when the config job confirmed the
# fetched release is the newest one (see FORCE_LATEST_FLAG above).
supersetbot docker \
--push \
--preset "$BUILD_PRESET" \
--context release \
--context-ref "$LATEST_RELEASE" \
$FORCE_LATEST_FLAG \
--platform "linux/arm64" \
--platform "linux/amd64"
# The whole point of this cron is catching base-image CVEs, so a silent
# failure is the expensive case — a red X in the Actions tab nobody is
# watching on a Monday. File a tracked issue when any rebuild leg fails so
# a missed security refresh surfaces instead of sitting unnoticed.
notify-on-failure:
needs: [config, docker-rebuild]
if: failure() && needs.config.outputs.has-secrets == '1'
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
steps:
- name: Open a tracking issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue create \
--repo "$REPOSITORY" \
--title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \
--label "infra:container" \
--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}"

View File

@@ -28,6 +28,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: './superset-websocket/.nvmrc'
- name: Install dependencies
working-directory: ./superset-websocket
run: npm ci

View File

@@ -24,6 +24,12 @@ on:
permissions:
contents: read
# Serialize with the scheduled Docker image refresh — both workflows push
# to the same Docker Hub tags and must not race on apache/superset:latest.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04

View File

@@ -24,6 +24,22 @@ assists people when migrating to a new version.
## Next
### Guest-token RLS rules reject unknown fields
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audience must be configured via `MCP_JWT_AUDIENCE` so issued tokens are bound to this service. The service now fails to start with a clear configuration error when the audience is unset, instead of starting with audience validation skipped. Deployments that enable MCP JWT auth must set `MCP_JWT_AUDIENCE` to the audience value their identity provider issues for the MCP service. API-key-only MCP deployments (JWT auth disabled) are unaffected.
### Swagger UI is opt-in (off by default)
`FAB_API_SWAGGER_UI` now defaults to `False` and is driven by the `SUPERSET_ENABLE_SWAGGER_UI` environment variable. The interactive Swagger UI / OpenAPI documentation endpoints (e.g. `/swagger/v1`) are therefore no longer exposed by default. To enable them, set `SUPERSET_ENABLE_SWAGGER_UI=true` (the bundled Docker development environment sets this) or override `FAB_API_SWAGGER_UI = True` in `superset_config.py`.
### Build details (git SHA / build number) are admin-only by default
The git SHA and build number surfaced in the "About" section, the bootstrap payload, and the public `/version` endpoint are now only included for admin users by default; the release version string is still shown to everyone. To expose the build details to all users (the previous behavior), set the `SUPERSET_EXPOSE_BUILD_DETAILS` environment variable (or `EXPOSE_BUILD_DETAILS_TO_USERS = True` in `superset_config.py`).
### Pivot table First/Last aggregations follow data order
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.

View File

@@ -70,6 +70,8 @@ SUPERSET_LOG_LEVEL=info
SUPERSET_APP_ROOT="/"
SUPERSET_ENV=development
# Swagger UI is opt-in (off by default); enable it for local development.
SUPERSET_ENABLE_SWAGGER_UI=true
SUPERSET_LOAD_EXAMPLES=yes
CYPRESS_CONFIG=false
SUPERSET_PORT=8088

View File

@@ -1 +0,0 @@
v24.16.0

1
docs/.nvmrc Symbolic link
View File

@@ -0,0 +1 @@
../superset-frontend/.nvmrc

View File

@@ -160,7 +160,7 @@ When enabled, Superset rejects webhook configurations that use `http://` URLs.
#### Retry Behavior
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff.
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.
### Kubernetes-specific

View File

@@ -223,8 +223,9 @@ compose based installation, edit the `x-superset-image:` line in your `docker-co
`docker-compose-non-dev.yml` files, replacing `apachesuperset.docker.scarf.sh/apache/superset` with
`apache/superset` to pull the image directly from Docker Hub.
To disable the Scarf telemetry pixel, set the `SCARF_ANALYTICS` environment variable to `False` in
your terminal and/or in your `docker/.env` file.
To disable the Scarf telemetry pixel, set the `SCARF_ANALYTICS` environment variable to `false` in
your `docker/.env` file. This is read at runtime, so it disables the pixel on the pre-built image
without rebuilding the frontend.
:::
## 3. Log in to Superset

View File

@@ -136,7 +136,17 @@ init:
:::note
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
To opt-out of this data collection in your Helm-based installation, edit the `repository:` line in your `helm/superset/values.yaml` file, replacing `apachesuperset.docker.scarf.sh/apache/superset` with `apache/superset` to pull the image directly from Docker Hub.
There are two independent telemetry channels:
- **Image pulls** (Scarf Gateway): to opt out, edit the `repository:` line in your `helm/superset/values.yaml` file, replacing `apachesuperset.docker.scarf.sh/apache/superset` with `apache/superset` to pull the image directly from Docker Hub.
- **The analytics pixel** rendered in the UI: to opt out, set the `SCARF_ANALYTICS` environment variable to `false` on the Superset containers via `extraEnv` in your `values.yaml`:
```yaml
extraEnv:
SCARF_ANALYTICS: "false"
```
This is read at runtime, so it takes effect on the pre-built images without rebuilding the frontend.
:::
### Dependencies

View File

@@ -161,6 +161,7 @@ Here's the documentation section how how to set up Talisman: https://superset.ap
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
- [ ] Rotate the other security-critical secrets (guest-token and async-query JWT secrets, SMTP and database credentials) on the cadence in Appendix C, and after any potential security incident.
- [ ] Conduct quarterly access reviews for all users.
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
@@ -173,6 +174,24 @@ Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is manda
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
### **Appendix C: Secrets Register and Rotation Schedule**
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign an owner, and rotate them on a defined cadence as well as after any suspected compromise.
| Secret | Purpose | Risk if leaked | Suggested rotation |
|---|---|---|---|
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
| `GLOBAL_ASYNC_QUERIES_JWT_SECRET` | Signs the async-query channel JWT | Forged async-query tokens | Quarterly + post-incident |
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |
Notes:
- Rotating `GUEST_TOKEN_JWT_SECRET` or `GLOBAL_ASYNC_QUERIES_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.
:::resources
- [Blog: Running Apache Superset on the Open Internet](https://preset.io/blog/running-apache-superset-on-the-open-internet-a-report-from-the-fireline/)
- [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/)

View File

@@ -34,15 +34,14 @@ Frontend contribution types allow extensions to extend Superset's user interface
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
```tsx
import React from 'react';
```typescript
import { views } from '@apache-superset/core';
import MyPanel from './MyPanel';
views.registerView(
{ id: 'my-extension.main', name: 'My Panel Name' },
'sqllab.panels',
() => <MyPanel />,
MyPanel,
);
```
@@ -112,6 +111,24 @@ editors.registerEditor(
See [Editors Extension Point](./extension-points/editors.md) for implementation details.
### Chat
Extensions can add a chat interface to Superset by registering a trigger component and a panel component. The host owns the layout, open/close state, and display mode — the extension only provides the UI. The panel can be displayed as a floating overlay or docked as a resizable sidebar beside the page content, and the user's preference is persisted across reloads.
```tsx
import { chat } from '@apache-superset/core';
import ChatTrigger from './ChatTrigger';
import ChatPanel from './ChatPanel';
chat.registerChat(
{ id: 'my-org.my-chat', name: 'My Chat' },
ChatTrigger,
ChatPanel,
);
```
See [Chat](./extension-points/chat.md) for implementation details.
## Backend
Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file.

View File

@@ -0,0 +1,141 @@
---
title: Chat
sidebar_position: 3
---
<!--
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.
-->
# Chat Contributions
Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components.
## Overview
A chat registration consists of two React components:
| Component | Role |
|-----------|------|
| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. |
| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. |
## Display Modes
The host supports two display modes, switchable by the user or the extension at runtime:
| Mode | Behavior |
|------|----------|
| `floating` | Panel floats above page content, anchored to the bottom-right corner. |
| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. |
The user's last selected mode and open/closed state are persisted across page reloads.
## Registering a Chat
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:
```tsx
import { chat } from '@apache-superset/core';
import ChatTrigger from './ChatTrigger';
import ChatPanel from './ChatPanel';
chat.registerChat(
{ id: 'my-org.my-chat', name: 'My Chat' },
ChatTrigger,
ChatPanel,
);
```
Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged.
## Opening and Closing the Chat
The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility:
```tsx
import { chat } from '@apache-superset/core';
export default function ChatTrigger() {
return (
<button onClick={() => (chat.isOpen() ? chat.close() : chat.open())}>
💬
</button>
);
}
```
You can also subscribe to open/close events from any component:
```tsx
useEffect(() => {
const { dispose } = chat.onDidOpen(() => console.log('chat opened'));
return dispose;
}, []);
```
## Changing the Display Mode
Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user):
```tsx
import { useState, useEffect } from 'react';
import { chat } from '@apache-superset/core';
export default function ChatPanel() {
const [mode, setMode] = useState(chat.getDisplayMode());
useEffect(() => {
const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m));
return dispose;
}, []);
return (
<div style={{ height: mode === 'panel' ? '100%' : '80vh' }}>
<button onClick={() => chat.setDisplayMode(mode === 'panel' ? 'floating' : 'panel')}>
{mode === 'panel' ? 'Float' : 'Dock'}
</button>
{/* message list and input */}
</div>
);
}
```
## Chat API Reference
All methods are available on the `chat` namespace from `@apache-superset/core`:
| Method / Event | Description |
|----------------|-------------|
| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. |
| `open()` | Open the chat panel. No-op if already open or no registration. |
| `close()` | Close the chat panel. |
| `isOpen()` | Returns `true` if the panel is currently open. |
| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). |
| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. |
| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. |
| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. |
| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. |
| `onDidRegisterChat(listener)` | Subscribe to registration events. |
| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. |
| `onDidResizePanel(listener)` | Subscribe to panel resize events (panel mode only). Not all hosts provide a resizer — do not rely on this firing. Returns a `Disposable`. |
## Next Steps
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
- **[Development](../development.md)** — Set up your development environment

View File

@@ -47,6 +47,8 @@ module.exports = {
collapsed: true,
items: [
'extensions/extension-points/sqllab',
'extensions/extension-points/editors',
'extensions/extension-points/chat',
],
},
'extensions/development',

View File

@@ -321,8 +321,8 @@ This can be used, for example, to convert UTC time to local time.
Superset uses [Scarf](https://about.scarf.sh/) by default to collect basic telemetry data upon installing and/or running Superset. This data helps the maintainers of Superset better understand which versions of Superset are being used, in order to prioritize patch/minor releases and security fixes.
We use the [Scarf Gateway](https://docs.scarf.sh/gateway/) to sit in front of container registries, the [scarf-js](https://about.scarf.sh/package-sdks) package to track `npm` installations, and a Scarf pixel to gather anonymous analytics on Superset page views.
Scarf purges PII and provides aggregated statistics. Superset users can easily opt out of analytics in various ways documented [here](https://docs.scarf.sh/gateway/#do-not-track) and [here](https://docs.scarf.sh/package-analytics/#as-a-user-of-a-package-using-scarf-js-how-can-i-opt-out-of-analytics).
Superset maintainers can also opt out of telemetry data collection by setting the `SCARF_ANALYTICS` environment variable to `false` in the Superset container (or anywhere Superset/webpack are run).
Additional opt-out instructions for Docker users are available on the [Docker Installation](/admin-docs/installation/docker-compose) page.
You can also opt out of the analytics pixel by setting the `SCARF_ANALYTICS` environment variable to `false`. This is read at runtime, so setting it on the Superset container (for example via `extraEnv` in the Helm chart, or `docker/.env` for Docker Compose) disables the pixel on the pre-built images without rebuilding the frontend. Note that this only disables the page-view pixel; the Scarf Gateway (container registry) and `scarf-js` (`npm`) channels are opted out separately, as described above.
Additional opt-out instructions are available on the [Docker Compose](/admin-docs/installation/docker-compose) and [Kubernetes](/admin-docs/installation/kubernetes) installation pages.
## Does Superset have an archive panel or trash bin from which a user can recover deleted assets?

View File

@@ -72,7 +72,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.41",
"antd": "^6.4.4",
"baseline-browser-mapping": "^2.10.37",
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
@@ -134,7 +134,8 @@
"yaml": "1.10.3",
"uuid": "11.1.1",
"serialize-javascript": "7.0.5",
"d3-color": "3.1.0"
"d3-color": "3.1.0",
"ws": "^8.21.0"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}

View File

@@ -5698,10 +5698,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.37"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d"
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.38"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
batch@0.6.1:
version "0.6.1"
@@ -15246,15 +15246,10 @@ write-file-atomic@^3.0.3:
signal-exit "^3.0.2"
typedarray-to-buffer "^3.1.5"
ws@^7.3.1:
version "7.5.10"
resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.18.0, ws@^8.2.3:
version "8.20.1"
resolved "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz"
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
ws@^7.3.1, ws@^8.18.0, ws@^8.2.3, ws@^8.21.0:
version "8.21.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951"
integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==
wsl-utils@^0.1.0:
version "0.1.0"

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.17.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.18.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.17.2](https://img.shields.io/badge/Version-0.17.2-informational?style=flat-square)
![Version: 0.18.0](https://img.shields.io/badge/Version-0.18.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
@@ -88,6 +88,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| ingress.path | string | `"/"` | |
| ingress.pathType | string | `"ImplementationSpecific"` | |
| ingress.tls | list | `[]` | |
| init.additionalPodSpec | object | `{}` | Custom pod spec to be added to init job |
| init.adminUser.email | string | `"admin@superset.com"` | |
| init.adminUser.firstname | string | `"Superset"` | |
| init.adminUser.lastname | string | `"Admin"` | |
@@ -131,6 +132,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| supersetCeleryBeat.affinity | object | `{}` | Affinity to be added to supersetCeleryBeat deployment |
| supersetCeleryBeat.command | list | a `celery beat` command | Command |
| supersetCeleryBeat.containerSecurityContext | object | `{}` | |
| supersetCeleryBeat.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetCeleryBeat deployment |
| supersetCeleryBeat.deploymentAnnotations | object | `{}` | Annotations to be added to supersetCeleryBeat deployment |
| supersetCeleryBeat.enabled | bool | `false` | This is only required if you intend to use alerts and reports |
| supersetCeleryBeat.extraContainers | list | `[]` | Launch additional containers into supersetCeleryBeat pods |
@@ -149,6 +151,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| supersetCeleryFlower.affinity | object | `{}` | Affinity to be added to supersetCeleryFlower deployment |
| supersetCeleryFlower.command | list | a `celery flower` command | Command |
| supersetCeleryFlower.containerSecurityContext | object | `{}` | |
| supersetCeleryFlower.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetCeleryFlower deployment |
| supersetCeleryFlower.deploymentAnnotations | object | `{}` | Annotations to be added to supersetCeleryFlower deployment |
| supersetCeleryFlower.enabled | bool | `false` | Enables a Celery flower deployment (management UI to monitor celery jobs) WARNING: on superset 1.x, this requires a Superset image that has `flower<1.0.0` installed (which is NOT the case of the default images) flower>=1.0.0 requires Celery 5+ which Superset 1.5 does not support |
| supersetCeleryFlower.extraContainers | list | `[]` | Launch additional containers into supersetCeleryFlower pods |
@@ -204,12 +207,14 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| supersetNode.connections.db_user | string | `"superset"` | |
| supersetNode.connections.redis_cache_db | string | `"1"` | |
| supersetNode.connections.redis_celery_db | string | `"0"` | |
| supersetNode.connections.redis_driver | string | `""` | |
| supersetNode.connections.redis_host | string | `"{{ .Release.Name }}-redis-headless"` | Change in case of bringing your own redis and then also set redis.enabled:false |
| supersetNode.connections.redis_port | string | `"6379"` | |
| supersetNode.connections.redis_ssl.enabled | bool | `false` | |
| supersetNode.connections.redis_ssl.ssl_cert_reqs | string | `"CERT_NONE"` | |
| supersetNode.connections.redis_user | string | `""` | |
| supersetNode.containerSecurityContext | object | `{}` | |
| supersetNode.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetNode deployment |
| supersetNode.deploymentAnnotations | object | `{}` | Annotations to be added to supersetNode deployment |
| supersetNode.deploymentLabels | object | `{}` | Labels to be added to supersetNode deployment |
| supersetNode.env | object | `{}` | |
@@ -255,6 +260,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| supersetWebsockets.command | list | `[]` | |
| supersetWebsockets.config | object | see `values.yaml` | The config.json to pass to the server, see https://github.com/apache/superset/tree/master/superset-websocket Note that the configuration can also read from environment variables (which will have priority), see https://github.com/apache/superset/blob/master/superset-websocket/src/config.ts for a list of supported variables |
| supersetWebsockets.containerSecurityContext | object | `{}` | |
| supersetWebsockets.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetWebsockets deployment |
| supersetWebsockets.deploymentAnnotations | object | `{}` | |
| supersetWebsockets.enabled | bool | `false` | This is only required if you intend to use `GLOBAL_ASYNC_QUERIES` in `ws` mode see https://superset.apache.org/docs/contributing/misc#async-chart-queries |
| supersetWebsockets.extraContainers | list | `[]` | Launch additional containers into supersetWebsockets pods |
@@ -308,6 +314,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| supersetWorker.autoscaling.targetCPUUtilizationPercentage | int | `80` | |
| supersetWorker.command | list | a `celery worker` command | Worker startup command |
| supersetWorker.containerSecurityContext | object | `{}` | |
| supersetWorker.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetWorker deployment |
| supersetWorker.deploymentAnnotations | object | `{}` | Annotations to be added to supersetWorker deployment |
| supersetWorker.deploymentLabels | object | `{}` | Labels to be added to supersetWorker deployment |
| supersetWorker.extraContainers | list | `[]` | Launch additional containers into supersetWorker pod |

View File

@@ -71,9 +71,9 @@ def env(key, default=None):
# Redis Base URL
{{- if .Values.supersetNode.connections.redis_password }}
REDIS_BASE_URL=f"{env('REDIS_PROTO')}://{env('REDIS_USER', '')}:{env('REDIS_PASSWORD')}@{env('REDIS_HOST')}:{env('REDIS_PORT')}"
REDIS_BASE_URL=f"{env('REDIS_DRIVER') or env('REDIS_PROTO')}://{env('REDIS_USER', '')}:{env('REDIS_PASSWORD')}@{env('REDIS_HOST')}:{env('REDIS_PORT')}"
{{- else }}
REDIS_BASE_URL=f"{env('REDIS_PROTO')}://{env('REDIS_HOST')}:{env('REDIS_PORT')}"
REDIS_BASE_URL=f"{env('REDIS_DRIVER') or env('REDIS_PROTO')}://{env('REDIS_HOST')}:{env('REDIS_PORT')}"
{{- end }}
# Redis URL Params
@@ -108,8 +108,6 @@ else:
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
{{- end }}
SQLALCHEMY_TRACK_MODIFICATIONS = True
class CeleryConfig:
imports = ("superset.sql_lab", )
broker_url = CELERY_REDIS_URL

View File

@@ -68,6 +68,9 @@ spec:
{{- toYaml .Values.supersetCeleryBeat.podLabels | nindent 8 }}
{{- end }}
spec:
{{- if .Values.supersetCeleryBeat.deploymentAdditionalPodSpec }}
{{- tpl (toYaml .Values.supersetCeleryBeat.deploymentAdditionalPodSpec) . | nindent 6 }}
{{- end }}
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
serviceAccountName: {{ template "superset.serviceAccountName" . }}
{{- end }}

View File

@@ -57,6 +57,9 @@ spec:
{{- toYaml .Values.supersetCeleryFlower.podLabels | nindent 8 }}
{{- end }}
spec:
{{- if .Values.supersetCeleryFlower.deploymentAdditionalPodSpec }}
{{- tpl (toYaml .Values.supersetCeleryFlower.deploymentAdditionalPodSpec) . | nindent 6 }}
{{- end }}
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
serviceAccountName: {{ template "superset.serviceAccountName" . }}
{{- end }}

View File

@@ -74,6 +74,9 @@ spec:
{{- toYaml .Values.supersetWorker.podLabels | nindent 8 }}
{{- end }}
spec:
{{- if .Values.supersetWorker.deploymentAdditionalPodSpec }}
{{- tpl (toYaml .Values.supersetWorker.deploymentAdditionalPodSpec) . | nindent 6 }}
{{- end }}
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
serviceAccountName: {{ template "superset.serviceAccountName" . }}
{{- end }}

View File

@@ -60,6 +60,9 @@ spec:
{{- toYaml .Values.supersetWebsockets.podLabels | nindent 8 }}
{{- end }}
spec:
{{- if .Values.supersetWebsockets.deploymentAdditionalPodSpec }}
{{- tpl (toYaml .Values.supersetWebsockets.deploymentAdditionalPodSpec) . | nindent 6 }}
{{- end }}
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
serviceAccountName: {{ template "superset.serviceAccountName" . }}
{{- end }}

View File

@@ -76,6 +76,9 @@ spec:
{{- toYaml .Values.supersetNode.podLabels | nindent 8 }}
{{- end }}
spec:
{{- if .Values.supersetNode.deploymentAdditionalPodSpec }}
{{- tpl (toYaml .Values.supersetNode.deploymentAdditionalPodSpec) . | nindent 6 }}
{{- end }}
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
serviceAccountName: {{ template "superset.serviceAccountName" . }}
{{- end }}

View File

@@ -41,16 +41,21 @@ spec:
{{- if .Values.init.podAnnotations }}
annotations: {{- toYaml .Values.init.podAnnotations | nindent 8 }}
{{- end }}
{{- if or .Values.extraLabels .Values.init.podLabels }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
job: {{ template "superset.fullname" . }}-init-db
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- if .Values.init.podLabels }}
{{- toYaml .Values.init.podLabels | nindent 8 }}
{{- end }}
{{- end }}
spec:
{{- if .Values.init.additionalPodSpec }}
{{- tpl (toYaml .Values.init.additionalPodSpec) . | nindent 6 }}
{{- end }}
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
serviceAccountName: {{ template "superset.serviceAccountName" . }}
{{- end }}

View File

@@ -39,6 +39,7 @@ stringData:
{{- end }}
REDIS_PORT: {{ .Values.supersetNode.connections.redis_port | quote }}
REDIS_PROTO: {{ if .Values.supersetNode.connections.redis_ssl.enabled }}"rediss"{{ else }}"redis"{{ end }}
REDIS_DRIVER: {{ .Values.supersetNode.connections.redis_driver | quote }}
REDIS_DB: {{ .Values.supersetNode.connections.redis_cache_db | quote }}
REDIS_CELERY_DB: {{ .Values.supersetNode.connections.redis_celery_db | quote }}
{{- if .Values.supersetNode.connections.redis_ssl.enabled }}

View File

@@ -283,6 +283,7 @@ supersetNode:
redis_ssl:
enabled: false
ssl_cert_reqs: CERT_NONE
redis_driver: ""
# You need to change below configuration incase bringing own PostgresSQL instance and also set postgresql.enabled:false
# -- Database type for Superset metadata (Supported types: "postgresql", "mysql")
db_type: "postgresql"
@@ -334,6 +335,8 @@ supersetNode:
deploymentAnnotations: {}
# -- Labels to be added to supersetNode deployment
deploymentLabels: {}
# -- Custom pod spec to be added to supersetNode deployment
deploymentAdditionalPodSpec: {}
# -- Affinity to be added to supersetNode deployment
affinity: {}
# -- TopologySpreadConstrains to be added to supersetNode deployments
@@ -459,6 +462,8 @@ supersetWorker:
deploymentAnnotations: {}
# -- Labels to be added to supersetWorker deployment
deploymentLabels: {}
# -- Custom pod spec to be added to supersetWorker deployment
deploymentAdditionalPodSpec: {}
# -- Affinity to be added to supersetWorker deployment
affinity: {}
# -- TopologySpreadConstrains to be added to supersetWorker deployments
@@ -565,6 +570,8 @@ supersetCeleryBeat:
extraContainers: []
# -- Annotations to be added to supersetCeleryBeat deployment
deploymentAnnotations: {}
# -- Custom pod spec to be added to supersetCeleryBeat deployment
deploymentAdditionalPodSpec: {}
# -- Affinity to be added to supersetCeleryBeat deployment
affinity: {}
# -- TopologySpreadConstrains to be added to supersetCeleryBeat deployments
@@ -680,6 +687,8 @@ supersetCeleryFlower:
extraContainers: []
# -- Annotations to be added to supersetCeleryFlower deployment
deploymentAnnotations: {}
# -- Custom pod spec to be added to supersetCeleryFlower deployment
deploymentAdditionalPodSpec: {}
# -- Affinity to be added to supersetCeleryFlower deployment
affinity: {}
# -- TopologySpreadConstrains to be added to supersetCeleryFlower deployments
@@ -757,6 +766,8 @@ supersetWebsockets:
# -- Launch additional containers into supersetWebsockets pods
extraContainers: []
deploymentAnnotations: {}
# -- Custom pod spec to be added to supersetWebsockets deployment
deploymentAdditionalPodSpec: {}
# -- Affinity to be added to supersetWebsockets deployment
affinity: {}
# -- TopologySpreadConstrains to be added to supersetWebsockets deployments
@@ -819,6 +830,8 @@ init:
jobAnnotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-delete-policy": "before-hook-creation"
# -- Custom pod spec to be added to init job
additionalPodSpec: {}
loadExamples: false
createAdmin: true
adminUser:

View File

@@ -108,7 +108,7 @@ dependencies = [
"simplejson>=4.1.1",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=30.8.0, <31",
# newer pandas needs 0.9+
"tabulate>=0.10.0, <1.0",
@@ -147,7 +147,7 @@ denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.10, <2"]
druid = ["pydruid>=0.6.5,<0.7"]
duckdb = ["duckdb>=1.5.2,<2", "duckdb-engine>=0.17.0"]
duckdb = ["duckdb>=1.5.4,<2", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.4.3"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
@@ -206,7 +206,7 @@ spark = [
"thrift>=0.23.0, <1",
]
tdengine = [
"taospy>=2.7.21",
"taospy>=2.8.9",
"taos-ws-py>=0.6.9"
]
teradata = ["teradatasql>=16.20.0.23"]

View File

@@ -43,5 +43,5 @@ filterwarnings =
# error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
# error:The Engine.execute\(\) method is considered legacy:sqlalchemy.exc.RemovedIn20Warning
error:The legacy calling style of select\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
# error:The "whens" argument to case:sqlalchemy.exc.RemovedIn20Warning
error:The "whens" argument to case:sqlalchemy.exc.RemovedIn20Warning
# error:"User" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning

View File

@@ -315,7 +315,7 @@ pygeohash==3.2.2
# via apache-superset (pyproject.toml)
pygments==2.20.0
# via rich
pyjwt==2.12.0
pyjwt==2.13.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
@@ -405,7 +405,7 @@ sqlalchemy==1.4.54
# marshmallow-sqlalchemy
# shillelagh
# sqlalchemy-utils
sqlalchemy-utils==0.42.0
sqlalchemy-utils==0.42.1
# via
# apache-superset (pyproject.toml)
# apache-superset-core

View File

@@ -220,7 +220,7 @@ docstring-parser==0.17.0
# via cyclopts
docutils==0.22.2
# via rich-rst
duckdb==1.5.3
duckdb==1.5.4
# via
# apache-superset
# duckdb-engine
@@ -769,7 +769,7 @@ pyhive==0.7.0
# via apache-superset
pyinstrument==5.1.2
# via apache-superset
pyjwt==2.12.0
pyjwt==2.13.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -976,7 +976,7 @@ sqlalchemy==1.4.54
# sqlalchemy-utils
sqlalchemy-bigquery==1.17.0
# via apache-superset
sqlalchemy-utils==0.42.0
sqlalchemy-utils==0.42.1
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -1 +0,0 @@
v24.16.0

View File

@@ -0,0 +1 @@
../superset-frontend/.nvmrc

View File

@@ -32,6 +32,7 @@ and therefore are not easily unit-testable. We have instead opted to test the sd
This way, the tests can assert that the sdk actually mounts the iframe and communicates with it correctly.
At time of writing, these tests are not written yet, because we haven't yet put together the demo app that they will leverage.
### Things to e2e test once we have a demo app:
**happy path:**

View File

@@ -41,12 +41,12 @@ npm install --save @superset-ui/embedded-sdk
```
```js
import { embedDashboard } from '@superset-ui/embedded-sdk';
import { embedDashboard } from "@superset-ui/embedded-sdk";
embedDashboard({
id: 'abc123', // given by the Superset embedding UI
supersetDomain: 'https://superset.example.com',
mountPoint: document.getElementById('my-superset-container'), // any html element that can contain an iframe
id: "abc123", // given by the Superset embedding UI
supersetDomain: "https://superset.example.com",
mountPoint: document.getElementById("my-superset-container"), // any html element that can contain an iframe
fetchGuestToken: () => fetchGuestTokenFromBackend(),
dashboardUiConfig: {
// dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional), urlParams (optional)
@@ -55,21 +55,21 @@ embedDashboard({
expanded: true,
},
urlParams: {
foo: 'value1',
bar: 'value2',
foo: "value1",
bar: "value2",
// themeMode: 'dark', // set the initial theme: 'dark' | 'system' | 'default' (default: 'default')
// ...
},
},
// optional additional iframe sandbox attributes
iframeSandboxExtras: [
'allow-top-navigation',
'allow-popups-to-escape-sandbox',
"allow-top-navigation",
"allow-popups-to-escape-sandbox",
],
// optional Permissions Policy features
iframeAllowExtras: ['clipboard-write', 'fullscreen'],
iframeAllowExtras: ["clipboard-write", "fullscreen"],
// optional config to enforce a particular referrerPolicy
referrerPolicy: 'same-origin',
referrerPolicy: "same-origin",
// optional callback to customize permalink URLs
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
});
@@ -163,13 +163,13 @@ Use the `themeMode` URL parameter to control the embedded dashboard's initial co
```js
embedDashboard({
id: 'abc123',
supersetDomain: 'https://superset.example.com',
mountPoint: document.getElementById('my-superset-container'),
id: "abc123",
supersetDomain: "https://superset.example.com",
mountPoint: document.getElementById("my-superset-container"),
fetchGuestToken: () => fetchGuestTokenFromBackend(),
dashboardUiConfig: {
urlParams: {
themeMode: 'dark', // 'dark' | 'system' | 'default' (default: 'default')
themeMode: "dark", // 'dark' | 'system' | 'default' (default: 'default')
},
},
});
@@ -193,7 +193,7 @@ To pass additional sandbox attributes you can use `iframeSandboxExtras`:
```js
// optional additional iframe sandbox attributes
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'];
iframeSandboxExtras: ["allow-top-navigation", "allow-popups-to-escape-sandbox"];
```
### Permissions Policy
@@ -202,7 +202,7 @@ To enable specific browser features within the embedded iframe, use `iframeAllow
```js
// optional Permissions Policy features
iframeAllowExtras: ['clipboard-write', 'fullscreen'];
iframeAllowExtras: ["clipboard-write", "fullscreen"];
```
Common permissions you might need:
@@ -225,9 +225,9 @@ When users click share buttons inside an embedded dashboard, Superset generates
```js
embedDashboard({
id: 'abc123',
supersetDomain: 'https://superset.example.com',
mountPoint: document.getElementById('my-superset-container'),
id: "abc123",
supersetDomain: "https://superset.example.com",
mountPoint: document.getElementById("my-superset-container"),
fetchGuestToken: () => fetchGuestTokenFromBackend(),
// Customize permalink URLs
@@ -245,9 +245,9 @@ To restore the dashboard state from a permalink in your app:
const permalinkKey = routeParams.key;
embedDashboard({
id: 'abc123',
supersetDomain: 'https://superset.example.com',
mountPoint: document.getElementById('my-superset-container'),
id: "abc123",
supersetDomain: "https://superset.example.com",
mountPoint: document.getElementById("my-superset-container"),
fetchGuestToken: () => fetchGuestTokenFromBackend(),
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
dashboardUiConfig: {

View File

@@ -18,9 +18,6 @@
*/
module.exports = {
presets: [
"@babel/preset-typescript",
"@babel/preset-env"
],
presets: ["@babel/preset-typescript", "@babel/preset-env"],
sourceMaps: true,
};

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
"scripts": {
"build": "tsc && babel src --out-dir lib --extensions '.ts,.tsx' && webpack --mode production",
"ci:release": "node ./release-if-necessary.js",
"test": "jest"
"test": "vitest --run --dir src"
},
"browserslist": [
"last 3 chrome versions",
@@ -41,12 +41,11 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/jest": "^29.5.12",
"@types/node": "^22.5.4",
"@types/node": "^25.4.0",
"babel-loader": "^9.1.3",
"jest": "^29.7.0",
"tscw-config": "^1.1.2",
"typescript": "^5.6.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4"
},

View File

@@ -17,15 +17,15 @@
* under the License.
*/
const { execSync } = require('child_process');
const { name, version } = require('./package.json');
const { execSync } = require("child_process");
const { name, version } = require("./package.json");
function log(...args) {
console.log('[embedded-sdk-release]', ...args);
console.log("[embedded-sdk-release]", ...args);
}
function logError(...args) {
console.error('[embedded-sdk-release]', ...args);
console.error("[embedded-sdk-release]", ...args);
}
(async () => {
@@ -38,13 +38,13 @@ function logError(...args) {
const { status } = await fetch(packageUrl);
if (status === 200) {
log('version already exists on npm, exiting');
log("version already exists on npm, exiting");
} else if (status === 404) {
log('release required, building');
log("release required, building");
try {
execSync('npm run build', { stdio: 'pipe' });
log('build successful, publishing')
execSync('npm publish --access public', { stdio: 'pipe' });
execSync("npm run build", { stdio: "pipe" });
log("build successful, publishing");
execSync("npm publish --access public", { stdio: "pipe" });
log(`published ${version} to npm`);
} catch (err) {
// npm writes failure details to stderr (auth/permission/registry
@@ -52,7 +52,7 @@ function logError(...args) {
// the real cause in CI logs.
if (err.stdout) console.error(String(err.stdout));
if (err.stderr) console.error(String(err.stderr));
logError('Encountered an error, details should be above');
logError("Encountered an error, details should be above");
process.exitCode = 1;
}
} else {

View File

@@ -18,7 +18,9 @@
*/
export const IFRAME_COMMS_MESSAGE_TYPE = "__embedded_comms__";
export const DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY: { [index: string]: any } = {
export const DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY: {
[index: string]: any;
} = {
visible: "show_filters",
expanded: "expand_filters",
}
};

View File

@@ -24,22 +24,23 @@ import {
DEFAULT_TOKEN_EXP_MS,
DEFAULT_TOKEN_REFRESH_RETRY_MS,
} from "./guestTokenRefresh";
import { afterAll, beforeAll, it, expect, describe, vi } from "vitest";
describe("guest token refresh", () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2022-03-03 01:00"));
jest.spyOn(global, "setTimeout");
vi.useFakeTimers();
vi.setSystemTime(new Date("2022-03-03 01:00"));
vi.spyOn(globalThis, "setTimeout");
});
afterAll(() => {
jest.useRealTimers();
vi.useRealTimers();
});
function makeFakeJWT(claims: any) {
// not a valid jwt, but close enough for this code
const tokenifiedClaims = Buffer.from(JSON.stringify(claims)).toString(
"base64"
"base64",
);
return `abc.${tokenifiedClaims}.xyz`;
}

View File

@@ -18,17 +18,23 @@
*/
import { jwtDecode } from "jwt-decode";
export const REFRESH_TIMING_BUFFER_MS = 5000 // refresh guest token early to avoid failed superset requests
export const MIN_REFRESH_WAIT_MS = 10000 // avoid blasting requests as fast as the cpu can handle
export const DEFAULT_TOKEN_EXP_MS = 300000 // (5 min) used only when parsing guest token exp fails
export const DEFAULT_TOKEN_REFRESH_RETRY_MS = 10000 // wait before retrying a failed/timed-out token refresh
export const REFRESH_TIMING_BUFFER_MS = 5000; // refresh guest token early to avoid failed superset requests
export const MIN_REFRESH_WAIT_MS = 10000; // avoid blasting requests as fast as the cpu can handle
export const DEFAULT_TOKEN_EXP_MS = 300000; // (5 min) used only when parsing guest token exp fails
export const DEFAULT_TOKEN_REFRESH_RETRY_MS = 10000; // wait before retrying a failed/timed-out token refresh
// when do we refresh the guest token?
export function getGuestTokenRefreshTiming(currentGuestToken: string) {
const parsedJwt = jwtDecode<Record<string, any>>(currentGuestToken);
// if exp is int, it is in seconds, but Date() takes milliseconds
const exp = new Date(/[^0-9\.]/g.test(parsedJwt.exp) ? parsedJwt.exp : parseFloat(parsedJwt.exp) * 1000);
const isValidDate = exp.toString() !== 'Invalid Date';
const ttl = isValidDate ? Math.max(MIN_REFRESH_WAIT_MS, exp.getTime() - Date.now()) : DEFAULT_TOKEN_EXP_MS;
const exp = new Date(
/[^0-9\.]/g.test(parsedJwt.exp)
? parsedJwt.exp
: parseFloat(parsedJwt.exp) * 1000,
);
const isValidDate = exp.toString() !== "Invalid Date";
const ttl = isValidDate
? Math.max(MIN_REFRESH_WAIT_MS, exp.getTime() - Date.now())
: DEFAULT_TOKEN_EXP_MS;
return ttl - REFRESH_TIMING_BUFFER_MS;
}

View File

@@ -20,15 +20,15 @@
import {
DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY,
IFRAME_COMMS_MESSAGE_TYPE,
} from './const';
} from "./const";
// We can swap this out for the actual switchboard package once it gets published
import { Switchboard } from '@superset-ui/switchboard';
import { Switchboard } from "@superset-ui/switchboard";
import {
getGuestTokenRefreshTiming,
DEFAULT_TOKEN_REFRESH_RETRY_MS,
} from './guestTokenRefresh';
import { withTimeout } from './withTimeout';
} from "./guestTokenRefresh";
import { withTimeout } from "./withTimeout";
/**
* The function to fetch a guest token from your Host App's backend server.
@@ -97,7 +97,7 @@ export type ObserveDataMaskCallbackFn = (
nativeFiltersChanged: boolean;
},
) => void;
export type ThemeMode = 'default' | 'dark' | 'system';
export type ThemeMode = "default" | "dark" | "system";
/**
* Callback to resolve permalink URLs.
@@ -113,12 +113,12 @@ export type EmbeddedDashboard = {
unmount: () => void;
getDashboardPermalink: (anchor: string) => Promise<string>;
getActiveTabs: () => Promise<string[]>;
observeDataMask: (
callbackFn: ObserveDataMaskCallbackFn,
) => void;
observeDataMask: (callbackFn: ObserveDataMaskCallbackFn) => void;
getDataMask: () => Promise<Record<string, any>>;
getChartStates: () => Promise<Record<string, any>>;
getChartDataPayloads: (params?: { chartId?: number }) => Promise<Record<string, any>>;
getChartDataPayloads: (params?: {
chartId?: number;
}) => Promise<Record<string, any>>;
setThemeConfig: (themeConfig: Record<string, any>) => void;
setThemeMode: (mode: ThemeMode) => void;
};
@@ -133,7 +133,7 @@ export async function embedDashboard({
fetchGuestToken,
dashboardUiConfig,
debug = false,
iframeTitle = 'Embedded Dashboard',
iframeTitle = "Embedded Dashboard",
iframeSandboxExtras = [],
iframeAllowExtras = [],
referrerPolicy,
@@ -152,13 +152,13 @@ export async function embedDashboard({
return withTimeout(
fetchGuestToken(),
guestTokenFetchTimeoutMs,
'fetchGuestToken',
"fetchGuestToken",
);
}
log('embedding');
log("embedding");
if (supersetDomain.endsWith('/')) {
if (supersetDomain.endsWith("/")) {
supersetDomain = supersetDomain.slice(0, -1);
}
@@ -185,15 +185,15 @@ export async function embedDashboard({
}
async function mountIframe(): Promise<Switchboard> {
return new Promise(resolve => {
const iframe = document.createElement('iframe');
return new Promise((resolve) => {
const iframe = document.createElement("iframe");
const dashboardConfigUrlParams = dashboardUiConfig
? { uiConfig: `${calculateConfig()}` }
: undefined;
const filterConfig = dashboardUiConfig?.filters || {};
const filterConfigKeys = Object.keys(filterConfig);
const filterConfigUrlParams = Object.fromEntries(
filterConfigKeys.map(key => [
filterConfigKeys.map((key) => [
DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY[key],
filterConfig[key],
]),
@@ -206,16 +206,16 @@ export async function embedDashboard({
...dashboardUiConfig?.urlParams,
};
const urlParamsString = Object.keys(urlParams).length
? '?' + new URLSearchParams(urlParams).toString()
: '';
? "?" + new URLSearchParams(urlParams).toString()
: "";
// set up the iframe's sandbox configuration
iframe.sandbox.add('allow-same-origin'); // needed for postMessage to work
iframe.sandbox.add('allow-scripts'); // obviously the iframe needs scripts
iframe.sandbox.add('allow-presentation'); // for fullscreen charts
iframe.sandbox.add('allow-downloads'); // for downloading charts as image
iframe.sandbox.add('allow-forms'); // for forms to submit
iframe.sandbox.add('allow-popups'); // for exporting charts as csv
iframe.sandbox.add("allow-same-origin"); // needed for postMessage to work
iframe.sandbox.add("allow-scripts"); // obviously the iframe needs scripts
iframe.sandbox.add("allow-presentation"); // for fullscreen charts
iframe.sandbox.add("allow-downloads"); // for downloading charts as image
iframe.sandbox.add("allow-forms"); // for forms to submit
iframe.sandbox.add("allow-popups"); // for exporting charts as csv
// additional sandbox props
iframeSandboxExtras.forEach((key: string) => {
iframe.sandbox.add(key);
@@ -226,7 +226,7 @@ export async function embedDashboard({
}
// add the event listener before setting src, to be 100% sure that we capture the load event
iframe.addEventListener('load', () => {
iframe.addEventListener("load", () => {
// MessageChannel allows us to send and receive messages smoothly between our window and the iframe
// See https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API
const commsChannel = new MessageChannel();
@@ -237,35 +237,35 @@ export async function embedDashboard({
// See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
// we know the content window isn't null because we are in the load event handler.
iframe.contentWindow!.postMessage(
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: 'port transfer' },
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: "port transfer" },
supersetDomain,
[theirPort],
);
log('sent message channel to the iframe');
log("sent message channel to the iframe");
// return our port from the promise
resolve(
new Switchboard({
port: ourPort,
name: 'superset-embedded-sdk',
name: "superset-embedded-sdk",
debug,
}),
);
});
iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`;
iframe.title = iframeTitle;
iframe.style.background = 'transparent';
iframe.style.background = "transparent";
// Permissions Policy features the embedded dashboard relies on. Modern
// browsers gate these APIs on the iframe's `allow` attribute regardless
// of sandbox flags, so we include them by default. Host apps can extend
// the list via `iframeAllowExtras`.
const allowFeatures = Array.from(
new Set(['fullscreen', 'clipboard-write', ...iframeAllowExtras]),
new Set(["fullscreen", "clipboard-write", ...iframeAllowExtras]),
);
iframe.setAttribute('allow', allowFeatures.join('; '));
iframe.setAttribute("allow", allowFeatures.join("; "));
//@ts-ignore
mountPoint.replaceChildren(iframe);
log('placed the iframe');
log("placed the iframe");
});
}
@@ -285,8 +285,8 @@ export async function embedDashboard({
throw err;
}
ourPort.emit('guestToken', { guestToken });
log('sent guest token');
ourPort.emit("guestToken", { guestToken });
log("sent guest token");
// Track the pending refresh timer so it can be cancelled on unmount, and
// stop the cycle once unmounted so it cannot leak across mount/unmount cycles.
@@ -298,7 +298,7 @@ export async function embedDashboard({
try {
const newGuestToken = await fetchGuestTokenWithTimeout();
if (unmounted) return;
ourPort.emit('guestToken', { guestToken: newGuestToken });
ourPort.emit("guestToken", { guestToken: newGuestToken });
refreshTimer = setTimeout(
refreshGuestToken,
getGuestTokenRefreshTiming(newGuestToken),
@@ -307,7 +307,7 @@ export async function embedDashboard({
// A transient fetch failure or timeout must not permanently stop the
// refresh cycle. Log it and retry so the session can recover once the
// host callback succeeds again.
log('failed to refresh guest token, will retry:', err);
log("failed to refresh guest token, will retry:", err);
if (unmounted) return;
refreshTimer = setTimeout(
refreshGuestToken,
@@ -325,7 +325,7 @@ export async function embedDashboard({
// Returns null if no callback provided or on error, allowing iframe to use default URL
ourPort.start();
ourPort.defineMethod(
'resolvePermalinkUrl',
"resolvePermalinkUrl",
async ({ key }: { key: string }): Promise<string | null> => {
if (!resolvePermalinkUrl) {
return null;
@@ -333,14 +333,14 @@ export async function embedDashboard({
try {
return await resolvePermalinkUrl({ key });
} catch (error) {
log('Error in resolvePermalinkUrl callback:', error);
log("Error in resolvePermalinkUrl callback:", error);
return null;
}
},
);
function unmount() {
log('unmounting');
log("unmounting");
unmounted = true;
if (refreshTimer !== undefined) {
clearTimeout(refreshTimer);
@@ -350,24 +350,25 @@ export async function embedDashboard({
mountPoint.replaceChildren();
}
const getScrollSize = () => ourPort.get<Size>('getScrollSize');
const getScrollSize = () => ourPort.get<Size>("getScrollSize");
const getDashboardPermalink = (anchor: string) =>
ourPort.get<string>('getDashboardPermalink', { anchor });
const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs');
const getDataMask = () => ourPort.get<Record<string, any>>('getDataMask');
const getChartStates = () => ourPort.get<Record<string, any>>('getChartStates');
ourPort.get<string>("getDashboardPermalink", { anchor });
const getActiveTabs = () => ourPort.get<string[]>("getActiveTabs");
const getDataMask = () => ourPort.get<Record<string, any>>("getDataMask");
const getChartStates = () =>
ourPort.get<Record<string, any>>("getChartStates");
const getChartDataPayloads = (params?: { chartId?: number }) =>
ourPort.get<Record<string, any>>('getChartDataPayloads', params);
const observeDataMask = (
callbackFn: ObserveDataMaskCallbackFn,
) => {
ourPort.defineMethod('observeDataMask', callbackFn);
ourPort.get<Record<string, any>>("getChartDataPayloads", params);
const observeDataMask = (callbackFn: ObserveDataMaskCallbackFn) => {
ourPort.defineMethod("observeDataMask", callbackFn);
};
// TODO: Add proper types once theming branch is merged
const setThemeConfig = async (themeConfig: Record<string, any>): Promise<void> => {
const setThemeConfig = async (
themeConfig: Record<string, any>,
): Promise<void> => {
try {
ourPort.emit('setThemeConfig', { themeConfig });
log('Theme config sent successfully (or at least message dispatched)');
ourPort.emit("setThemeConfig", { themeConfig });
log("Theme config sent successfully (or at least message dispatched)");
} catch (error) {
log(
'Error sending theme config. Ensure the iframe side implements the "setThemeConfig" method.',
@@ -378,7 +379,7 @@ export async function embedDashboard({
const setThemeMode = (mode: ThemeMode): void => {
try {
ourPort.emit('setThemeMode', { mode });
ourPort.emit("setThemeMode", { mode });
log(`Theme mode set to: ${mode}`);
} catch (error) {
log(

View File

@@ -18,22 +18,23 @@
*/
import { withTimeout } from "./withTimeout";
import { test, expect } from "vitest";
test("resolves with the value when the promise settles in time", async () => {
await expect(withTimeout(Promise.resolve("ok"), 1000, "fetch")).resolves.toBe(
"ok"
"ok",
);
});
test("rejects when the promise does not settle within the timeout", async () => {
const never = new Promise<string>(() => {});
await expect(withTimeout(never, 10, "fetch")).rejects.toThrow(
/fetch did not resolve within 10ms/
/fetch did not resolve within 10ms/,
);
});
test("passes the promise through unchanged when the timeout is disabled", async () => {
await expect(withTimeout(Promise.resolve("ok"), 0, "fetch")).resolves.toBe(
"ok"
"ok",
);
});

View File

@@ -3,7 +3,7 @@
// syntax rules
"strict": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
// environment
"target": "es6",
@@ -13,7 +13,9 @@
// output
"outDir": "./dist",
"emitDeclarationOnly": true,
"declaration": true
"declaration": true,
"types": ["node"]
},
"include": [
@@ -21,7 +23,6 @@
],
"exclude": [
"tests",
"dist",
"lib",
"node_modules"

View File

@@ -17,19 +17,19 @@
* under the License.
*/
const path = require('path');
const path = require("path");
module.exports = {
entry: './src/index.ts',
entry: "./src/index.ts",
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'bundle'),
filename: "index.js",
path: path.resolve(__dirname, "bundle"),
// this exposes the library's exports under a global variable
library: {
name: "supersetEmbeddedSdk",
type: "umd"
}
type: "umd",
},
},
devtool: "source-map",
module: {
@@ -38,12 +38,12 @@ module.exports = {
test: /\.[tj]s$/,
// babel-loader is faster than ts-loader because it ignores types.
// We do type checking in a separate process, so that's fine.
use: 'babel-loader',
use: "babel-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.ts', '.js'],
extensions: [".ts", ".js"],
},
};

View File

@@ -1,118 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { DATABASE_LIST } from 'cypress/utils/urls';
function closeModal() {
cy.get('body').then($body => {
if ($body.find('[data-test="database-modal"]').length) {
cy.get('[aria-label="Close"]').eq(1).click();
}
});
}
describe('Add database', () => {
before(() => {
cy.visit(DATABASE_LIST);
});
beforeEach(() => {
cy.intercept('POST', '**/api/v1/database/validate_parameters/**').as(
'validateParams',
);
cy.intercept('POST', '**/api/v1/database/').as('createDb');
closeModal();
cy.getBySel('btn-create-database').click();
});
it('should open dynamic form', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').should('have.value', '');
cy.get('input[name="port"]').should('have.value', '');
cy.get('input[name="database"]').should('have.value', '');
cy.get('input[name="username"]').should('have.value', '');
cy.get('input[name="password"]').should('have.value', '');
cy.get('input[name="database_name"]').should('have.value', '');
});
it('should open sqlalchemy form', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.getBySel('sqla-connect-btn').click();
cy.getBySel('database-name-input').should('be.visible');
cy.getBySel('sqlalchemy-uri-input').should('be.visible');
});
it('show error alerts on dynamic form for bad host', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('badhost', { force: true });
cy.get('input[name="port"]').type('5432', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.get('body').click(0, 0);
cy.wait('@validateParams', { timeout: 30000 });
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
"The hostname provided can't be resolved",
).should('exist');
});
});
});
it('show error alerts on dynamic form for bad port', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('localhost', { force: true });
cy.get('body').click(0, 0);
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5430', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.wait('@validateParams');
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.get('body').click(0, 0);
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
'The port is closed',
).should('exist');
});
});
});
});

View File

@@ -27,17 +27,6 @@
"tscw-config": "^1.1.2"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
"integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.0"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
@@ -48,33 +37,35 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.4.tgz",
"integrity": "sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.17.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.5.tgz",
"integrity": "sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.16.7",
"@babel/generator": "^7.17.3",
"@babel/helper-compilation-targets": "^7.16.7",
"@babel/helper-module-transforms": "^7.16.7",
"@babel/helpers": "^7.17.2",
"@babel/parser": "^7.17.3",
"@babel/template": "^7.16.7",
"@babel/traverse": "^7.17.3",
"@babel/types": "^7.17.0",
"convert-source-map": "^1.7.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.1.2",
"semver": "^6.3.0"
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
@@ -85,23 +76,94 @@
}
},
"node_modules/@babel/core/node_modules/@babel/code-frame": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
"@babel/highlight": "^7.16.7"
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"node_modules/@babel/core/node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core/node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core/node_modules/@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/@babel/core/node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/@babel/core/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
"node_modules/@babel/generator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -139,6 +201,7 @@
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz",
"integrity": "sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg==",
"peer": true,
"dependencies": {
"@babel/compat-data": "^7.21.4",
"@babel/helper-validator-option": "^7.21.0",
@@ -157,6 +220,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"peer": true,
"dependencies": {
"yallist": "^3.0.2"
}
@@ -164,7 +228,8 @@
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"peer": true
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.21.4",
@@ -256,9 +321,10 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -279,6 +345,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
@@ -291,6 +358,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
@@ -396,25 +464,28 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
"integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -435,13 +506,13 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.26.9",
"@babel/types": "^7.26.10"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -451,6 +522,7 @@
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
"peer": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
@@ -461,11 +533,12 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1593,24 +1666,26 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template/node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -1619,16 +1694,17 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -1636,11 +1712,12 @@
}
},
"node_modules/@babel/traverse/node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -1649,12 +1726,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -2093,6 +2171,16 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@@ -3353,6 +3441,7 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"peer": true,
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
@@ -3366,6 +3455,7 @@
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"peer": true,
"dependencies": {
"color-convert": "^1.9.0"
},
@@ -3491,6 +3581,7 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"peer": true,
"dependencies": {
"color-name": "1.1.3"
}
@@ -3498,7 +3589,8 @@
"node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"peer": true
},
"node_modules/colorette": {
"version": "1.4.0",
@@ -4984,6 +5076,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"peer": true,
"engines": {
"node": ">=4"
}
@@ -7878,6 +7971,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"peer": true,
"dependencies": {
"has-flag": "^3.0.0"
},
@@ -8770,14 +8864,6 @@
}
},
"dependencies": {
"@ampproject/remapping": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
"integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==",
"requires": {
"@jridgewell/trace-mapping": "^0.3.0"
}
},
"@babel/code-frame": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
@@ -8788,49 +8874,100 @@
}
},
"@babel/compat-data": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.4.tgz",
"integrity": "sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g=="
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="
},
"@babel/core": {
"version": "7.17.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.5.tgz",
"integrity": "sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"requires": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.16.7",
"@babel/generator": "^7.17.3",
"@babel/helper-compilation-targets": "^7.16.7",
"@babel/helper-module-transforms": "^7.16.7",
"@babel/helpers": "^7.17.2",
"@babel/parser": "^7.17.3",
"@babel/template": "^7.16.7",
"@babel/traverse": "^7.17.3",
"@babel/types": "^7.17.0",
"convert-source-map": "^1.7.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.1.2",
"semver": "^6.3.0"
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"requires": {
"@babel/highlight": "^7.16.7"
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}
},
"@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"requires": {
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
}
},
"@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"requires": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
}
},
"@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"requires": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
}
},
"convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"requires": {
"yallist": "^3.0.2"
}
},
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}
}
},
"@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"requires": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -8859,6 +8996,7 @@
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz",
"integrity": "sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg==",
"peer": true,
"requires": {
"@babel/compat-data": "^7.21.4",
"@babel/helper-validator-option": "^7.21.0",
@@ -8871,6 +9009,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"peer": true,
"requires": {
"yallist": "^3.0.2"
}
@@ -8878,7 +9017,8 @@
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"peer": true
}
}
},
@@ -8948,9 +9088,9 @@
}
},
"@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="
},
"@babel/helper-member-expression-to-functions": {
"version": "7.21.0",
@@ -8965,6 +9105,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"peer": true,
"requires": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
@@ -8974,6 +9115,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"peer": true,
"requires": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
@@ -9049,19 +9191,19 @@
}
},
"@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="
},
"@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="
},
"@babel/helper-validator-option": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
"integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ=="
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="
},
"@babel/helper-wrap-function": {
"version": "7.20.5",
@@ -9076,18 +9218,19 @@
}
},
"@babel/helpers": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"requires": {
"@babel/template": "^7.26.9",
"@babel/types": "^7.26.10"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
}
},
"@babel/highlight": {
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
"peer": true,
"requires": {
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
@@ -9095,11 +9238,11 @@
}
},
"@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"requires": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
}
},
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
@@ -9851,21 +9994,21 @@
}
},
"@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"requires": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"requires": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}
@@ -9873,25 +10016,25 @@
}
},
"@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"requires": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"requires": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}
@@ -9899,12 +10042,12 @@
}
},
"@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"requires": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
}
},
"@colors/colors": {
@@ -10226,7 +10369,7 @@
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "^3.13.1",
"js-yaml": "4.1.1",
"resolve-from": "^5.0.0"
},
"dependencies": {
@@ -10236,7 +10379,8 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"js-yaml": {
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"requires": {
"argparse": "^2.0.1"
@@ -10258,6 +10402,15 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"requires": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@@ -11317,6 +11470,7 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"peer": true,
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
@@ -11327,6 +11481,7 @@
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"peer": true,
"requires": {
"color-convert": "^1.9.0"
}
@@ -11419,6 +11574,7 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"peer": true,
"requires": {
"color-name": "1.1.3"
}
@@ -11426,7 +11582,8 @@
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"peer": true
},
"colorette": {
"version": "1.4.0",
@@ -12546,7 +12703,8 @@
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"peer": true
},
"has-symbols": {
"version": "1.1.0",
@@ -12872,7 +13030,7 @@
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz",
"integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==",
"requires": {
"@babel/core": "^7.7.5",
"@babel/core": "^7.29.6",
"@istanbuljs/schema": "^0.1.2",
"istanbul-lib-coverage": "^3.0.0",
"semver": "^6.3.0"
@@ -14580,6 +14738,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"peer": true,
"requires": {
"has-flag": "^3.0.0"
}

View File

@@ -34,6 +34,7 @@
"tscw-config": "^1.1.2"
},
"overrides": {
"@babel/core": "^7.29.6",
"cypress": {
"form-data": "^2.3.4"
},

File diff suppressed because it is too large Load Diff

View File

@@ -192,13 +192,13 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.24.1",
"mapbox-gl": "^3.25.0",
"markdown-to-jsx": "^9.8.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
"mustache": "^4.2.0",
"nanoid": "^5.1.11",
"nanoid": "^5.1.14",
"ol": "^10.9.0",
"query-string": "9.4.0",
"re-resizable": "^6.11.2",
@@ -283,7 +283,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.9.3",
"@types/node": "^26.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -303,7 +303,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.37",
"baseline-browser-mapping": "^2.10.38",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
@@ -350,7 +350,7 @@
"process": "^0.11.10",
"react-dnd-test-backend": "^16.0.1",
"react-refresh": "^0.18.0",
"react-resizable": "^4.0.1",
"react-resizable": "^4.0.2",
"redux-mock-store": "^1.5.4",
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
@@ -388,6 +388,10 @@
"overrides": {
"uuid": "$uuid",
"core-js": "^3.38.1",
"dompurify": "^3.4.11",
"esbuild": "^0.28.1",
"http-proxy-middleware": "^2.0.10",
"tar": "^7.5.16",
"puppeteer": "^22.4.1",
"remark-gfm": "^3.0.1",
"underscore": "^1.13.7",

View File

@@ -18,6 +18,14 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": {
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"

View File

@@ -0,0 +1,156 @@
/**
* 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.
*/
/**
* @fileoverview Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type: an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
import { ComponentType } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat. */
description?: string;
}
export type DisplayMode = 'floating' | 'panel';
/**
* Registers a chat provider. Only one chat is active at a time; the most
* recently registered chat wins. Disposing the returned Disposable unregisters
* the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger The trigger component — the collapsed bubble entry point.
* Owns dynamic state such as unread counts.
* @param panel The panel component, rendered in either display mode. In
* 'floating' mode it appears as an overlay; in 'panel' mode it is docked
* alongside the main content.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: ComponentType,
panel: ComponentType,
): Disposable;
/**
* Returns the active chat descriptor, or undefined if none is registered.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens. Also fired by the host's own
* controls, not only by an extension's open() call.
*/
export declare const onDidOpen: Event<void>;
/**
* Event fired when the chat panel closes, whether triggered by an extension
* or by the host.
*/
export declare const onDidClose: Event<void>;
/**
* Returns the current display mode.
*/
export declare function getDisplayMode(): DisplayMode;
/**
* Sets the display mode. The mode is host-global and applies to whichever
* chat is active. Use {@link onDidChangeDisplayMode} to observe all changes,
* including those triggered by the host.
*/
export declare function setDisplayMode(displayMode: DisplayMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setDisplayMode() or by host-provided controls.
*/
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
/**
* Event fired when the panel is resized in panel mode. Not all hosts provide
* a resizer — do not rely on this event firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.

View File

@@ -223,8 +223,6 @@ export interface Extension {
dependencies: string[];
/** Human-readable description of the extension */
description: string;
/** List of other extensions that this extension depends on */
extensionDependencies: string[];
/** Unique identifier for the extension */
id: string;
/** Human-readable name of the extension */

View File

@@ -23,9 +23,10 @@
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema.
* menus, editors, chat) and re-exported here for the manifest schema.
*/
import { Chat } from '../chat';
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
@@ -71,7 +72,8 @@ export interface MenuContributions {
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
* Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
@@ -82,4 +84,10 @@ export interface Contributions {
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
/**
* The chat contributed by the extension — at most one per extension, since
* the host applies singleton resolution and renders exactly one active
* chat at a time.
*/
chat?: Chat;
}

View File

@@ -18,10 +18,12 @@
*/
export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab';
export * as views from './views';
export * as contributions from './contributions';

View File

@@ -0,0 +1,81 @@
/**
* 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.
*/
/**
* @fileoverview Navigation namespace for Superset extensions.
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — surface-specific namespaces that
* resolve entity payloads are introduced in later phases.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
* the welcome surface and the fallback for any route not explicitly enumerated.
*/
export type Page =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home';
/**
* Returns the current page surface.
*
* @example
* ```typescript
* const page = navigation.getPage();
* if (page === 'dashboard') {
* // react to being on a dashboard surface
* }
* ```
*/
export declare function getPage(): Page;
/**
* Event fired whenever the user navigates to a different surface.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(page => {
* if (page === 'dashboard') {
* // react to navigating onto a dashboard surface
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<Page>;

View File

@@ -30,12 +30,12 @@
*
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
* () => <ResultStatsPanel />,
* ResultStatsPanel,
* );
* ```
*/
import { ReactElement } from 'react';
import { ComponentType } from 'react';
import { Disposable, Event } from '../common';
/**
@@ -58,7 +58,7 @@ export interface View {
*
* @param view The view descriptor (id and name).
* @param location The location where this view should appear (e.g. "sqllab.panels").
* @param provider A function that returns the React element to render.
* @param component The React component to render at that location.
* @returns A Disposable that unregisters the view when disposed.
*
* @example
@@ -66,14 +66,14 @@ export interface View {
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats' },
* 'sqllab.panels',
* () => <ResultStatsPanel />,
* ResultStatsPanel,
* );
* ```
*/
export declare function registerView(
view: View,
location: string,
provider: () => ReactElement,
component: ComponentType,
): Disposable;
/**

View File

@@ -132,6 +132,26 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = {
},
},
],
[
{
name: 'time_compare_full_range',
config: {
type: 'CheckboxControl',
label: t('Show full range for time shift'),
default: false,
description: t(
'Plot each time-shifted series across its full time range instead ' +
'of truncating it to the main series. Useful for comparing a ' +
'partial current period (e.g. today so far) against complete ' +
'prior periods (e.g. all of yesterday).',
),
visibility: ({ controls }) =>
Boolean(controls?.time_compare?.value) &&
(!Array.isArray(controls?.time_compare?.value) ||
controls.time_compare.value.length > 0),
},
},
],
[
{
name: 'comparison_type',

View File

@@ -60,7 +60,7 @@ export default function RadioButtonControl({
...props
}: RadioButtonControlProps) {
const normalizedOptions = options.map(normalizeOption);
const currentValue = initialValue || normalizedOptions[0].value;
const currentValue = initialValue ?? normalizedOptions[0]?.value;
return (
<div>

View File

@@ -359,6 +359,51 @@ test('handles empty options array gracefully', () => {
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
});
test('currentValue is undefined when options are empty and no value is provided', () => {
expect(() => setup({ options: [] })).not.toThrow();
const { container } = setup({ options: [] });
expect(container.querySelectorAll('[id^="tab-"]').length).toBe(0);
});
test('preserves falsy numeric value 0 instead of falling back to first option', () => {
const { container } = setup({
options: [
[0, 'Zero'],
[1, 'One'],
[2, 'Two'],
],
value: 0,
});
expect(container.querySelector('#tab-0')).toHaveAttribute(
'aria-selected',
'true',
);
expect(container.querySelector('#tab-1')).toHaveAttribute(
'aria-selected',
'false',
);
});
test('preserves falsy boolean value false instead of falling back to first option', () => {
const { container } = setup({
options: [
[true, 'True'],
[false, 'False'],
],
value: false,
});
expect(container.querySelector('#tab-true')).toHaveAttribute(
'aria-selected',
'false',
);
expect(container.querySelector('#tab-false')).toHaveAttribute(
'aria-selected',
'true',
);
});
test('renders with hovered prop', () => {
const { container } = setup({
label: 'Test',

View File

@@ -22,21 +22,50 @@ under the License.
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/core)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Fcore?style=flat)](https://libraries.io/npm/@superset-ui%2Fcore)
Description
The core package for Apache Superset's frontend. It provides shared utilities,
types, and abstractions used across all Superset chart plugins and UI components.
Key modules include:
- **query** — Utilities for building queries and calling the Superset API
(including `makeApi`)
- **number-format** — Number formatting helpers powered by d3-format
- **time-format** — Time/date formatting helpers powered by d3-time-format
- **connection** — `SupersetClient`, the HTTP client for the Superset REST API
- **chart** — Base classes and types for building chart plugins
> **Note:** i18n utilities (`t`, `tn`, etc.) are no longer part of this package.
> They now live in `@apache-superset/core`, imported from
> `@apache-superset/core/translation`.
#### Example usage
```js
import { xxx } from '@superset-ui/core';
import { getNumberFormatter, makeApi } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
// Format a number
const formatter = getNumberFormatter('.2f');
console.log(formatter(1234.5)); // "1234.50"
// Translate a string
console.log(t('Hello %s', 'world'));
// Call a Superset API endpoint
const fetchDashboards = makeApi({
method: 'GET',
endpoint: '/api/v1/dashboard',
});
```
#### API
`fn(args)`
- TBD
### Development
`@data-ui/build-config` is used to manage the build configuration for this package including babel
builds, jest testing, eslint, and prettier.
`@data-ui/build-config` is used to manage the build configuration for this package
including babel builds, jest testing, eslint, and prettier.
Run tests:
```bash
cd superset-frontend
npx jest packages/superset-ui-core
```

View File

@@ -52,7 +52,7 @@
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.6.0",
"react-draggable": "^4.7.0",
"react-error-boundary": "6.0.0",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
@@ -77,7 +77,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^25.9.3",
"@types/node": "^26.0.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -45,6 +45,7 @@ export const IconTooltip = forwardRef<HTMLElement, IconTooltipProps>(
}}
buttonStyle="link"
className={`IconTooltip ${className}`}
aria-label={tooltip ?? undefined}
>
{children}
</Button>

View File

@@ -24,7 +24,10 @@ import { Modal } from '../Modal';
export interface ModalTriggerProps {
dialogClassName?: string;
triggerNode: ReactNode;
modalTitle?: string;
// Accept ReactNode so callers can pass rich titles (e.g. a full
// <ControlHeader> with description tooltip + validation badges +
// warning icons). String remains valid for the common case.
modalTitle?: ReactNode;
modalBody?: ReactNode; // not required because it can be generated by beforeOpen
modalFooter?: ReactNode;
beforeOpen?: Function;
@@ -110,7 +113,9 @@ export const ModalTrigger = forwardRef(
className={className}
show={showModal}
onHide={close}
name={modalTitle}
// `name` is used for data-test / telemetry and must be a string;
// `title` accepts arbitrary ReactNode for rich rendering.
name={typeof modalTitle === 'string' ? modalTitle : undefined}
title={modalTitle}
footer={modalFooter}
hideFooter={!modalFooter}

View File

@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
import remarkGfm from 'remark-gfm';
import { mergeWith } from 'lodash';
import { cloneDeep, mergeWith } from 'lodash';
import { FeatureFlag, isFeatureEnabled } from '../../utils';
interface SafeMarkdownProps {
@@ -85,8 +85,15 @@ export function getOverrideHtmlSchema(
originalSchema: typeof defaultSchema,
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
) {
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) =>
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
// Merge into a fresh clone: mergeWith mutates its first argument, and the
// array customizer concatenates, so merging into the shared defaultSchema
// import would progressively widen the sanitization allowlist for every
// SafeMarkdown instance app-wide.
return mergeWith(
cloneDeep(originalSchema),
htmlSchemaOverrides,
(objValue, srcValue) =>
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
);
}

View File

@@ -113,7 +113,7 @@ const AsyncSelect = forwardRef(
allowClear,
allowNewOptions = false,
ariaLabel,
autoClearSearchValue = false,
autoClearSearchValue = true,
fetchOnlyOnSearch,
filterOption = true,
header = null,
@@ -267,6 +267,12 @@ const AsyncSelect = forwardRef(
});
fireOnChange();
}
if (autoClearSearchValue) {
setInputValue('');
if (fetchOnlyOnSearch) {
setSelectOptions([]);
}
}
onSelect?.(selectedItem, option);
};

View File

@@ -404,7 +404,7 @@ AdvancedPlayground.args = {
autoFocus: true,
allowNewOptions: false,
allowClear: false,
autoClearSearchValue: false,
autoClearSearchValue: true,
allowSelectAll: true,
disabled: false,
invertSelection: false,

View File

@@ -1146,6 +1146,127 @@ test('pasting an non-existent option should not add it if allowNewOptions is fal
expect(await findAllSelectOptions()).toHaveLength(0);
});
// Reference for the bug this tests: https://github.com/apache/superset/issues/32645
// Dashboard filters with "Dynamically search all filter values" only load a
// page of options client-side, so a pasted value outside that page used to be
// silently dropped. allowNewOptionsOnPaste keeps such values so the filter can
// still apply them.
test('keeps pasted values outside loaded options when allowNewOptionsOnPaste is true', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
// Liam is a loaded option; OutsideValue is not in the loaded page.
getData: () => 'Liam,OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
// The paste handler appends, so the loaded option resolves first.
expect(values).toEqual(['Liam', 'OutsideValue']);
});
// Assert the unloaded value actually reaches the change handler (the value
// that gets applied to the filter query), not just the rendered label.
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ value: 'OutsideValue' }),
]),
expect.anything(),
);
});
test('trims whitespace around pasted comma-separated values', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
// Note the space after the comma — it must not leak into the value.
getData: () => 'Liam, OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual(['Liam', 'OutsideValue']);
});
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ value: 'OutsideValue' }),
]),
expect.anything(),
);
});
test('does not create an empty option when pasting blank text', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => ' ',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual([]);
});
// No empty-string value should ever reach the handler.
onChange.mock.calls.forEach(([value]) => {
expect(value).not.toContain('');
});
});
test('drops pasted values outside loaded options when allowNewOptionsOnPaste is false', async () => {
render(<Select {...defaultProps} mode="multiple" allowNewOptions={false} />);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'Liam,OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual(['Liam']);
});
});
test('does not fire onChange if the same value is selected in single mode', async () => {
const onChange = jest.fn();
render(<Select {...defaultProps} onChange={onChange} />);

View File

@@ -91,9 +91,10 @@ const Select = forwardRef(
className,
allowClear,
allowNewOptions = false,
allowNewOptionsOnPaste = false,
allowSelectAll = true,
ariaLabel,
autoClearSearchValue = false,
autoClearSearchValue = true,
filterOption = true,
header = null,
headerPosition = 'top',
@@ -333,6 +334,11 @@ const Select = forwardRef(
});
fireOnChange();
}
if (autoClearSearchValue) {
setInputValue('');
setIsSearching(false);
setVisibleOptions(fullSelectOptions);
}
onSelect?.(selectedItem, option);
};
@@ -692,20 +698,34 @@ const Select = forwardRef(
}
} else {
const token = tokenSeparators.find(token => pastedText.includes(token));
const array = token ? uniq(pastedText.split(token)) : [pastedText];
const array = token
? uniq(
pastedText
.split(token)
.map(item => item.trim())
.filter(Boolean),
)
: [pastedText.trim()].filter(Boolean);
const newOptions: SelectOptionsType = [];
// When `allowNewOptionsOnPaste` is set, accept pasted values that are
// not in the loaded options even if `allowNewOptions` is false. The
// full option set is searched server-side and only partially loaded
// client-side, so a pasted value can legitimately exist in the dataset
// but fall outside the loaded page.
const keepUnknownValues = allowNewOptions || allowNewOptionsOnPaste;
const values = array
.map(item => {
const option = getOption(item, fullSelectOptions, true);
if (!option && allowNewOptions) {
if (!option && keepUnknownValues) {
const newOption = {
label: item,
value: item,
isNewOption: true,
};
newOptions.push(newOption);
return labelInValue ? { label: item, value: item } : item;
}
return getPastedTextValue(item);
})

View File

@@ -88,6 +88,18 @@ export interface BaseSelectProps extends AntdExposedProps {
* False by default.
* */
allowNewOptions?: boolean;
/**
* Accept values pasted into the Select even when they are not part of the
* currently loaded options and `allowNewOptions` is false. Useful for
* selects whose full option set is searched server-side and only partially
* loaded on the client (e.g. dashboard filters with "Dynamically search all
* filter values"), where a pasted value can legitimately exist in the
* dataset but fall outside the loaded page.
* Only applies to multi-select paste; single-select paste resolves through
* `allowNewOptions` and ignores this flag.
* False by default.
* */
allowNewOptionsOnPaste?: boolean;
/**
* It adds the aria-label tag for accessibility standards.
* Must be plain English and localized.

View File

@@ -33,10 +33,15 @@ test('should render', () => {
test('should render the pixel link when FF is on', () => {
process.env.SCARF_ANALYTICS = 'true';
render(<TelemetryPixel />);
render(<TelemetryPixel version="1.2.3" sha="abc" build="42" />);
const image = document.querySelector('img[src*="scarf.sh"]');
// Hits Scarf's static pixel directly, not the gateway redirect that browsers flag
const image = document.querySelector('img[src^="https://static.scarf.sh/"]');
expect(image).toBeInTheDocument();
expect(image?.getAttribute('src')).toContain('version=1.2.3');
expect(image?.getAttribute('src')).toContain('sha=abc');
expect(image?.getAttribute('src')).toContain('build=42');
expect(document.querySelector('img[src*="gateway.scarf.sh"]')).toBeNull();
});
test('should NOT render the pixel link when FF is off', () => {
@@ -46,3 +51,19 @@ test('should NOT render the pixel link when FF is off', () => {
const image = document.querySelector('img[src*="scarf.sh"]');
expect(image).not.toBeInTheDocument();
});
test('should NOT render the pixel link when disabled at runtime', () => {
process.env.SCARF_ANALYTICS = 'true';
render(<TelemetryPixel enabled={false} />);
const image = document.querySelector('img[src*="scarf.sh"]');
expect(image).not.toBeInTheDocument();
});
test('should render the pixel link when enabled at runtime', () => {
process.env.SCARF_ANALYTICS = 'true';
render(<TelemetryPixel enabled />);
const image = document.querySelector('img[src*="scarf.sh"]');
expect(image).toBeInTheDocument();
});

View File

@@ -23,17 +23,25 @@ interface TelemetryPixelProps {
version?: string;
sha?: string;
build?: string;
enabled?: boolean;
}
/**
* Renders a telemetry pixel component to capture anonymous, aggregated telemetry via Scarf.
* This can be disabled by setting the SCARF_ANALYTICS environment variable to false.
*
* Telemetry can be disabled in two ways:
* - At build time, by setting the SCARF_ANALYTICS environment variable to `false`
* (inlined by webpack; only effective when building the frontend yourself).
* - At runtime, by passing `enabled={false}`, which the app derives from the
* `SCARF_ANALYTICS` backend config exposed via the bootstrap payload. This is
* what allows opting out in pre-built images, where the build-time flag is fixed.
*
* @component
* @param {TelemetryPixelProps} props - The props for the TelemetryPixel component.
* @param {string} props.version - The version of Superset that's currently in use.
* @param {string} props.sha - The SHA of Superset that's currently in use.
* @param {string} props.build - The build of Superset that's currently in use.
* @param {boolean} props.enabled - Runtime opt-out switch; when false the pixel is not rendered.
* @returns {JSX.Element | null} The rendered TelemetryPixel component.
*/
@@ -43,9 +51,18 @@ export const TelemetryPixel = ({
version = 'unknownVersion',
sha = 'unknownSHA',
build = 'unknownBuild',
enabled = true,
}: TelemetryPixelProps): ReactElement | null => {
const pixelPath = `https://apachesuperset.gateway.scarf.sh/pixel/${PIXEL_ID}/${version}/${sha}/${build}`;
return process.env.SCARF_ANALYTICS === 'false' ? null : (
// Use Scarf's native static pixel directly rather than the gateway redirect
// (apachesuperset.gateway.scarf.sh), which some browsers/extensions flag as a
// tracking redirect. The gateway route forwards to this same static endpoint.
const pixelPath =
`https://static.scarf.sh/a.png?x-pxid=${PIXEL_ID}` +
`&version=${encodeURIComponent(version)}` +
`&sha=${encodeURIComponent(sha)}` +
`&build=${encodeURIComponent(build)}`;
const disabled = !enabled || process.env.SCARF_ANALYTICS === 'false';
return disabled ? null : (
<img
referrerPolicy="no-referrer-when-downgrade"
src={pixelPath}

View File

@@ -52,6 +52,7 @@ const SupersetClient: SupersetClientInterface = {
request: request => getInstance().request(request),
getCSRFToken: () => getInstance().getCSRFToken(),
getUrl: (...args) => getInstance().getUrl(...args),
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
get guestTokenHeaderName() {
try {
return getInstance().guestTokenHeaderName;

View File

@@ -150,6 +150,26 @@ export default class SupersetClientClass {
}
}
/**
* POST request that returns a blob for file downloads.
* Unlike postForm, this uses AJAX so errors can be caught and handled.
* @param endpoint - API endpoint
* @param payload - Request payload
* @returns Promise resolving to Response with blob
*/
async postBlob(
endpoint: string,
payload: Record<string, any>,
): Promise<Response> {
await this.ensureAuth();
return this.post({
endpoint,
postPayload: payload,
parseMethod: 'raw',
stringify: false,
});
}
async reAuthenticate() {
return this.init(true);
}

View File

@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
| 'get'
| 'post'
| 'postForm'
| 'postBlob'
| 'put'
| 'request'
| 'init'

View File

@@ -17,6 +17,8 @@
* under the License.
*/
import { render } from '@testing-library/react';
import { cloneDeep } from 'lodash';
import { defaultSchema } from 'rehype-sanitize';
import {
getOverrideHtmlSchema,
SafeMarkdown,
@@ -51,6 +53,36 @@ describe('getOverrideHtmlSchema', () => {
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']);
});
test('should not mutate the original schema', () => {
const original = {
attributes: { '*': ['size'] },
tagNames: ['h1'],
};
getOverrideHtmlSchema(original, {
attributes: { '*': ['src'] },
tagNames: ['iframe'],
});
// The original passed in is left untouched.
expect(original.attributes).toEqual({ '*': ['size'] });
expect(original.tagNames).toEqual(['h1']);
});
test('should not mutate the shared defaultSchema import or accumulate across calls', () => {
const snapshot = cloneDeep(defaultSchema);
const overrides = { tagNames: ['iframe'] };
const first = getOverrideHtmlSchema(defaultSchema, overrides);
const second = getOverrideHtmlSchema(defaultSchema, overrides);
// The shared singleton is never modified...
expect(defaultSchema).toEqual(snapshot);
// ...and repeated calls do not accumulate the override (no growing arrays).
expect(first.tagNames).toEqual(second.tagNames);
expect(
(second.tagNames ?? []).filter(name => name === 'iframe'),
).toHaveLength(1);
});
});
describe('transformLinkUri', () => {

View File

@@ -36,12 +36,13 @@ describe('SupersetClient', () => {
getUrl: (...args: unknown[]) => string;
};
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
expect(typeof SupersetClient.configure).toBe('function');
expect(typeof SupersetClient.init).toBe('function');
expect(typeof SupersetClient.get).toBe('function');
expect(typeof SupersetClient.post).toBe('function');
expect(typeof SupersetClient.postForm).toBe('function');
expect(typeof SupersetClient.postBlob).toBe('function');
expect(typeof SupersetClient.delete).toBe('function');
expect(typeof SupersetClient.put).toBe('function');
expect(typeof SupersetClient.request).toBe('function');
@@ -53,11 +54,12 @@ describe('SupersetClient', () => {
expect(typeof SupersetClient.reAuthenticate).toBe('function');
});
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
expect(SupersetClient.init).toThrow();
expect(SupersetClient.get).toThrow();
expect(SupersetClient.post).toThrow();
expect(SupersetClient.postForm).toThrow();
expect(SupersetClient.postBlob).toThrow();
expect(SupersetClient.delete).toThrow();
expect(SupersetClient.put).toThrow();
expect(SupersetClient.request).toThrow();

View File

@@ -780,4 +780,75 @@ describe('SupersetClientClass', () => {
expect(authSpy).toHaveBeenCalledTimes(0);
});
});
describe('.postBlob()', () => {
const protocol = 'https:';
const host = 'host';
const mockPostBlobEndpoint = '/api/v1/chart/data';
const mockPostBlobUrl = `${protocol}//${host}${mockPostBlobEndpoint}`;
const postBlobPayload = { form_data: '{"viz_type":"table"}' };
let authSpy: jest.SpyInstance;
let client: SupersetClientClass;
beforeEach(async () => {
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
client = new SupersetClientClass({ protocol, host });
await client.init();
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('calls ensureAuth and delegates to post with raw parseMethod', async () => {
const mockResponse = new Response('csv data', { status: 200 });
const postSpy = jest
.spyOn(client, 'post')
.mockResolvedValue(mockResponse);
const response = await client.postBlob(
mockPostBlobEndpoint,
postBlobPayload,
);
expect(authSpy).toHaveBeenCalledTimes(1);
expect(postSpy).toHaveBeenCalledWith({
endpoint: mockPostBlobEndpoint,
postPayload: postBlobPayload,
parseMethod: 'raw',
stringify: false,
});
expect(response).toBe(mockResponse);
});
test('passes payload in request body', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 200,
body: 'csv data',
});
await client.postBlob(mockPostBlobEndpoint, postBlobPayload);
const fetchRequest = fetchMock.callHistory.calls(mockPostBlobUrl)[0]
.options as CallApi;
const formData = fetchRequest.body as FormData;
expect(formData.get('form_data')).toBe(postBlobPayload.form_data);
});
test('rejects when response is not ok', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 413,
body: 'Payload Too Large',
});
await expect(
client.postBlob(mockPostBlobEndpoint, postBlobPayload),
).rejects.toMatchObject({ status: 413 });
});
});
});

File diff suppressed because one or more lines are too long

View File

@@ -305,36 +305,16 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
key: JSON.stringify,
},
done: datamap => {
// Hover highlighting and its reset are handled entirely by Datamaps'
// built-in highlightOnHover, which saves each country's original fill on
// mouseover and restores it on mouseout. Adding our own mouseover/mouseout
// fill handlers here creates a second, competing restore path whose
// execution order is browser-timing-dependent, which left the highlight
// stuck on Chrome/Edge (see #37761).
datamap.svg
.selectAll('.datamaps-subunit')
.on('contextmenu', handleContextMenu)
.on('click', handleClick)
// Use namespaced events to avoid overriding Datamaps' default tooltip handlers
.on('mouseover.fillPreserve', function onMouseOver() {
if (inContextMenu) {
return;
}
const element = d3.select(this);
const classes = element.attr('class') || '';
const countryId = classes.split(' ')[1];
const countryData = mapData[countryId];
const originalFill =
(countryData && countryData.fillColor) || theme.colorBorder;
// Store original fill color for restoration
element.attr('data-original-fill', originalFill);
})
.on('mouseout.fillPreserve', function onMouseOut() {
if (inContextMenu) {
return;
}
const element = d3.select(this);
const originalFill = element.attr('data-original-fill');
// Restore the original fill color (data-based or default no-data color)
if (originalFill) {
element.style('fill', originalFill);
element.attr('data-original-fill', null);
}
});
.on('click', handleClick);
},
});

View File

@@ -58,15 +58,6 @@ interface WorldMapProps {
formatter: ValueFormatter;
}
type MouseEventHandler = (this: HTMLElement) => void;
interface MockD3Selection {
attr: jest.Mock;
style: jest.Mock;
classed: jest.Mock;
selectAll: jest.Mock;
}
// Mock Datamap
const mockBubbles = jest.fn();
const mockUpdateChoropleth = jest.fn();
@@ -157,244 +148,36 @@ afterEach(() => {
document.body.removeChild(container);
});
test('sets up mouseover and mouseout handlers on countries', () => {
test('relies on Datamaps highlightOnHover without adding conflicting fill handlers', () => {
// Regression test for #37761. The hover highlight got stuck on Chrome/Edge
// because hand-written mouseover/mouseout handlers competed with Datamaps'
// built-in highlightOnHover restore path, and the winning path was
// browser-timing-dependent. The chart should rely on the single built-in
// path and register no custom fill-restoring hover handlers on the countries.
WorldMap(container, baseProps);
expect(mockSvg.selectAll).toHaveBeenCalledWith('.datamaps-subunit');
const onCalls = mockSvg.on.mock.calls;
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
string,
unknown
>;
expect(geographyConfig?.highlightOnHover).toBe(true);
// Find mouseover and mouseout handler registrations (namespaced events)
const hasMouseover = onCalls.some(
call => call[0] === 'mouseover.fillPreserve',
const hoverHandlers = mockSvg.on.mock.calls.filter((call: [string]) =>
/^mouse(over|out)/.test(call[0]),
);
const hasMouseout = onCalls.some(call => call[0] === 'mouseout.fillPreserve');
expect(hasMouseover).toBe(true);
expect(hasMouseout).toBe(true);
expect(hoverHandlers).toEqual([]);
});
test('stores original fill color on mouseover', () => {
// Create a mock DOM element with d3 selection capabilities
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
container.appendChild(mockElement);
test('disables Datamaps highlightOnHover while the context menu is open', () => {
// Companion to the regression guard above: when the context menu is open we
// pass highlightOnHover: false so hover highlighting is suppressed at init.
WorldMap(container, { ...baseProps, inContextMenu: true });
let mouseoverHandler: MouseEventHandler | null = null;
// Mock d3.select to return the mock element
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string) => {
if (value !== undefined) {
mockElement.setAttribute(attrName, value);
} else {
return mockElement.getAttribute(attrName);
}
return mockD3Selection;
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
} else {
return mockElement.style[styleName as any];
}
return mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseover handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseover.fillPreserve') {
mouseoverHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseover
if (mouseoverHandler) {
(mouseoverHandler as MouseEventHandler).call(mockElement);
}
// Verify that data-original-fill attribute was set
expect(mockD3Selection.attr).toHaveBeenCalledWith(
'data-original-fill',
expect.any(String),
);
});
test('restores original fill color on mouseout for country with data', () => {
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
mockElement.setAttribute('data-original-fill', 'rgb(100, 150, 200)');
container.appendChild(mockElement);
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string | null) => {
if (value !== undefined) {
if (value === null) {
mockElement.removeAttribute(attrName);
} else {
mockElement.setAttribute(attrName, value);
}
return mockD3Selection;
}
return mockElement.getAttribute(attrName);
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
}
return mockElement.style[styleName as any] || mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseout handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseout
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// Verify that original fill was restored
expect(mockD3Selection.style).toHaveBeenCalledWith(
'fill',
'rgb(100, 150, 200)',
);
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
});
test('restores default fill color on mouseout for country with no data', () => {
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit XXX');
mockElement.style.fill = '#e0e0e0'; // Default border color
mockElement.setAttribute('data-original-fill', '#e0e0e0');
container.appendChild(mockElement);
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string | null) => {
if (value !== undefined) {
if (value === null) {
mockElement.removeAttribute(attrName);
} else {
mockElement.setAttribute(attrName, value);
}
return mockD3Selection;
}
return mockElement.getAttribute(attrName);
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
}
return mockElement.style[styleName as any] || mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseout handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseout
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// Verify that default fill was restored (no-data color)
expect(mockD3Selection.style).toHaveBeenCalledWith('fill', '#e0e0e0');
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
});
test('does not handle mouse events when inContextMenu is true', () => {
const propsWithContextMenu = {
...baseProps,
inContextMenu: true,
};
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
container.appendChild(mockElement);
let mouseoverHandler: MouseEventHandler | null = null;
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn(() => mockD3Selection),
style: jest.fn(() => mockD3Selection),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture namespaced event handlers
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseover.fillPreserve') {
mouseoverHandler = handler;
}
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, propsWithContextMenu);
// Simulate mouseover and mouseout
if (mouseoverHandler) {
(mouseoverHandler as MouseEventHandler).call(mockElement);
}
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// When inContextMenu is true, handlers should exit early without modifying anything
// We verify this by checking that attr and style weren't called to change fill
const attrCalls = mockD3Selection.attr.mock.calls;
const fillChangeCalls = attrCalls.filter(
(call: [string, unknown]) =>
call[0] === 'data-original-fill' && call[1] !== undefined,
);
const styleCalls = mockD3Selection.style.mock.calls;
const fillStyleChangeCalls = styleCalls.filter(
(call: [string, unknown]) => call[0] === 'fill' && call[1] !== undefined,
);
// The handlers should return early, so no state changes
expect(fillChangeCalls.length).toBe(0);
expect(fillStyleChangeCalls.length).toBe(0);
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
string,
unknown
>;
expect(geographyConfig?.highlightOnHover).toBe(false);
});
test('does not throw error when onContextMenu is undefined', () => {

View File

@@ -90,6 +90,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject;
let postProcessing: PostProcessingRule[] = [];
// Capture the percent-metric `contribution` rule so it can be reused for
// the totals query below. The totals query must rename percent-metric
// columns the same way (`metric` -> `%metric`) so the footer can look them
// up; without it the totals row renders 0.000%. We deliberately reuse only
// this rule and not the full `postProcessing` array, which may also contain
// a time-comparison operator that must not run on the single totals row.
let contributionPostProcessing: PostProcessingRule | undefined;
const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
@@ -157,15 +164,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
metrics.concat(percentMetrics),
getMetricLabel,
);
postProcessing = [
{
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
contributionPostProcessing = {
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
];
};
postProcessing = [contributionPostProcessing];
}
// Add the operator for the time comparison if some is selected
if (!isEmpty(timeOffsets)) {
@@ -658,7 +664,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
extras: totalsExtras, // Use extras with AG Grid WHERE removed
row_limit: 0,
row_offset: 0,
post_processing: [],
// Reapply only the percent-metric contribution rule so the totals row
// exposes `%metric` keys (value/value = 100% on the single aggregated
// row). The time-comparison operator from the main query is omitted on
// purpose; it must not run against the single-row totals query.
post_processing: contributionPostProcessing
? [contributionPostProcessing]
: [],
order_desc: undefined, // we don't need orderby stuff here,
orderby: undefined, // because this query will be used for get total aggregation.
});

View File

@@ -852,6 +852,75 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.columns).toEqual([]);
expect(totalsQuery.row_limit).toBe(0);
});
test('should reapply percent-metric contribution op to totals query', () => {
// Regression test for #37627: when a percent metric is configured and
// Show Summary (show_totals) is enabled, the totals query must rename
// percent-metric columns (`metric` -> `%metric`) so the footer can
// look them up. Otherwise the totals row renders 0.000%.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
// No server pagination -> queries[1] is the totals query.
const totalsQuery = queries[1];
const contributionRule = {
operation: 'contribution',
options: {
columns: ['count'],
rename_columns: ['%count'],
},
};
expect(queries[0].post_processing).toContainEqual(contributionRule);
expect(totalsQuery.post_processing).toEqual([contributionRule]);
});
test('should omit time-comparison op from totals post_processing', () => {
// The totals query must reuse ONLY the contribution rule; the
// time-comparison operator from the main query must not run against
// the single-row totals query.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
time_compare: ['1 year ago'],
comparison_type: 'values',
});
const totalsQuery = queries[1];
// Exactly one op (contribution) — the time-comparison operator from the
// main query must not be carried over to the single-row totals query.
expect(totalsQuery.post_processing).toHaveLength(1);
expect(totalsQuery.post_processing?.[0]).toMatchObject({
operation: 'contribution',
});
// The reused rule matches the main query's contribution rule verbatim.
expect(totalsQuery.post_processing?.[0]).toEqual(
queries[0].post_processing?.find(
op => op?.operation === 'contribution',
),
);
});
test('should leave totals post_processing empty without percent metrics', () => {
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
const totalsQuery = queries[1];
expect(totalsQuery.post_processing).toEqual([]);
});
});
describe('Integration - all filter types together', () => {

View File

@@ -231,6 +231,56 @@ describe('BigNumberTotal transformProps', () => {
expect(result.headerFormatter(500)).toBe('$500');
});
test('should pass through non-numeric raw string when parseMetricValue returns null (e.g. VARCHAR MAX)', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: 'some-varchar-result' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('some-varchar-result');
});
test('should pass through numeric-looking VARCHAR string literally (e.g. "123")', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: '123' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('123');
});
test('should propagate colorThresholdFormatters from getColorFormatters', () => {
// Override the getColorFormatters mock to return specific value
const mockFormatters = [{ formatter: 'red' }];

View File

@@ -79,8 +79,15 @@ export default function transformProps(
const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
: (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER);
const rawValue = data.length === 0 ? null : data[0][metricName];
const parsedValue = rawValue == null ? null : parseMetricValue(rawValue);
const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
parsedValue === null &&
typeof rawValue === 'string' &&
rawValue.trim() !== ''
? rawValue
: parsedValue;
let metricEntry: Metric | undefined;
if (chartProps.datasource?.metrics) {

View File

@@ -189,8 +189,10 @@ function BigNumberVis({
text = t('No data');
} else if (typeof bigNumber === 'number') {
text = headerFormatter(bigNumber);
} else if (typeof bigNumber === 'string') {
text = bigNumber;
} else {
// For string/boolean/Date values, convert to number if possible, else show as string
// For boolean/Date values, convert to number if possible, else show as string
const numValue = Number(bigNumber);
text = Number.isNaN(numValue)
? String(bigNumber)

View File

@@ -318,14 +318,25 @@ function createAdvancedAnalyticsSection(
): ControlPanelSectionConfig {
const aaWithSuffix = cloneDeep(sections.advancedAnalyticsControls);
aaWithSuffix.label = label;
// `time_compare_full_range` is only wired into the regular timeseries query
// builder, not the mixed-timeseries one, so drop it here to avoid showing a
// control that has no effect.
aaWithSuffix.controlSetRows = aaWithSuffix.controlSetRows
.map(row =>
row.filter(
control =>
(control as CustomControlItem)?.name !== 'time_compare_full_range',
),
)
.filter(row => row.length > 0);
if (!controlSuffix) {
return aaWithSuffix;
}
aaWithSuffix.controlSetRows.forEach(row =>
row.forEach((control: CustomControlItem) => {
if (control?.name) {
// eslint-disable-next-line no-param-reassign
control.name = `${control.name}${controlSuffix}`;
row.forEach(control => {
const item = control as CustomControlItem;
if (item?.name) {
item.name = `${item.name}${controlSuffix}`;
}
}),
);

View File

@@ -344,6 +344,16 @@ export default function transformProps(
data2,
currencyCodeColumn,
);
const getAxisFormatterConfig = (axisIndex?: number) =>
axisIndex === 1
? {
customFormatters: customFormattersSecondary,
formatter: formatterSecondary,
}
: {
customFormatters,
formatter,
};
const primarySeries = new Set<string>();
const secondarySeries = new Set<string>();
@@ -422,6 +432,8 @@ export default function transformProps(
let [minSecondary, maxSecondary] = (yAxisBoundsSecondary || []).map(
parseAxisBound,
);
const getAxisMax = (axisIndex?: number) =>
axisIndex === 1 ? maxSecondary : yAxisMax;
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
@@ -445,10 +457,11 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
}
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndex);
const seriesFormatter = getFormatter(
customFormatters,
formatter,
axisFormatterConfig.customFormatters,
axisFormatterConfig.formatter,
metrics,
labelMap?.[seriesName]?.[0],
!!contributionMode,
@@ -480,7 +493,7 @@ export default function transformProps(
formatter:
seriesType === EchartsTimeseriesSeriesType.Bar
? getOverMaxHiddenFormatter({
max: yAxisMax,
max: getAxisMax(yAxisIndex),
formatter: seriesFormatter,
})
: seriesFormatter,
@@ -518,10 +531,11 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
}
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndexB);
const seriesFormatter = getFormatter(
customFormattersSecondary,
formatterSecondary,
axisFormatterConfig.customFormatters,
axisFormatterConfig.formatter,
metricsB,
labelMapB?.[seriesName]?.[0],
!!contributionMode,
@@ -554,7 +568,7 @@ export default function transformProps(
formatter:
seriesTypeB === EchartsTimeseriesSeriesType.Bar
? getOverMaxHiddenFormatter({
max: maxSecondary,
max: getAxisMax(yAxisIndexB),
formatter: seriesFormatter,
})
: seriesFormatter,

View File

@@ -331,10 +331,16 @@ export default function transformProps(
type: legendType,
});
const chartPadding = getChartPadding(
showLegend,
legendOrientation,
effectiveLegendMargin,
);
const series: RadarSeriesOption[] = [
{
type: 'radar',
...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
...chartPadding,
animation: false,
emphasis: {
label: {
@@ -361,6 +367,15 @@ export default function transformProps(
numberFormatter,
);
const centerX = width
? ((width + chartPadding.left - chartPadding.right) / 2 / width) * 100
: 50;
const centerY = height
? ((height + chartPadding.top - chartPadding.bottom) / 2 / height) * 100
: 50;
const radarCenter: [string, string] = [`${centerX}%`, `${centerY}%`];
const echartOptions: EChartsCoreOption = {
grid: {
...defaultGrid,
@@ -390,6 +405,7 @@ export default function transformProps(
color: theme.colorSplit,
},
},
center: radarCenter,
splitArea: {
show: true,
areaStyle: {

View File

@@ -92,6 +92,20 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'show_null_values',
config: {
type: 'CheckboxControl',
label: t('Show Null Values'),
default: true,
renderTrigger: true,
description: t(
'Whether to display entries with null values in the hierarchy',
),
},
},
],
[
{
name: 'label_type',

View File

@@ -186,6 +186,9 @@ export default function transformProps(
showLabels,
showLabelsThreshold,
showTotal,
// Default to true so charts saved before this control existed keep
// showing null values instead of silently hiding them on upgrade.
showNullValues = true,
sliceId,
} = formData;
const {
@@ -251,6 +254,7 @@ export default function transformProps(
columnLabels,
metricLabel,
secondaryMetricLabel,
!showNullValues,
);
const totalValue = treeData.reduce(
(result, treeNode) => result + treeNode.value,

View File

@@ -23,6 +23,7 @@ import {
} from '../../../../spec/helpers/testing-library';
import { AxisType } from '@superset-ui/core';
import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react';
import {
LegendOrientation,
@@ -202,11 +203,15 @@ const defaultProps: TimeseriesChartTransformedProps = {
onFocusedSeries: jest.fn(),
};
function getLatestHeight() {
function getLatestEchartProps() {
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
return props.height;
return props;
}
function getLatestHeight() {
return getLatestEchartProps().height;
}
test('observes extra control height changes when ResizeObserver is available', async () => {
@@ -335,6 +340,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: ['Product A', 100], // X-axis value is 'Product A'
name: 'Product A',
@@ -361,6 +367,149 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
}
});
test('emits cross-filter on category value for horizontal bar clicks', async () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [100, 'Product A'],
name: 'Product A',
dataIndex: 0,
});
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'category_column',
op: 'IN',
val: ['Product A'],
},
]);
});
test('uses rendered categorical axis for query event handlers', () => {
render(
<EchartsTimeseries
{...defaultProps}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'xAxis.category',
);
cleanup();
mockEchart.mockReset();
render(
<EchartsTimeseries
{...defaultProps}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'yAxis.category',
);
});
test('emits cross-filter from horizontal categorical axis label clicks', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const labelClickHandler =
getLatestEchartProps().queryEventHandlers?.[0].handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
value: 'Product A',
} as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'category_column',
op: 'IN',
val: ['Product A'],
},
]);
});
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'xAxis',
name: 'Product A',
});
await new Promise(resolve => setTimeout(resolve, 400));
expect(setDataMaskMock).not.toHaveBeenCalled();
});
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
const setDataMaskMock = jest.fn();
@@ -385,6 +534,7 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales',
data: [1609459200000, 100], // Timestamp
name: '2021-01-01',
@@ -407,6 +557,10 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
...defaultProps,
emitCrossFilters: true,
setDataMask: setDataMaskMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
groupby: [], // No dimensions
xAxis: {
label: 'category_column',
@@ -423,6 +577,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: [100, 'Product A'], // Horizontal: value first, category second
name: 'Product A',
@@ -457,6 +612,10 @@ test('context menu cross-filter uses the category value for a horizontal categor
...defaultProps,
emitCrossFilters: true,
onContextMenu: onContextMenuMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
groupby: [], // No dimensions
xAxis: {
label: 'category_column',
@@ -474,6 +633,7 @@ test('context menu cross-filter uses the category value for a horizontal categor
expect(contextMenuHandler).toBeDefined();
if (contextMenuHandler) {
await contextMenuHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: [100, 'Product A'], // Horizontal: value first, category second
name: 'Product A',

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DTTM_ALIAS,
BinaryQueryObjectFilterClause,
@@ -27,12 +27,15 @@ import {
LegendState,
ensureIsArray,
} from '@superset-ui/core';
import type { ViewRootGroup } from 'echarts/types/src/util/types';
import type {
ECElementEvent,
ViewRootGroup,
} from 'echarts/types/src/util/types';
import type GlobalModel from 'echarts/types/src/model/Global';
import type ComponentModel from 'echarts/types/src/model/Component';
import { EchartsHandler, EventHandlers } from '../types';
import Echart from '../components/Echart';
import { TimeseriesChartTransformedProps } from './types';
import { OrientationType, TimeseriesChartTransformedProps } from './types';
import { formatSeriesName } from '../utils/series';
import { ExtraControls } from '../components/ExtraControls';
@@ -218,6 +221,26 @@ export default function EchartsTimeseries({
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
const canCrossFilterByXAxis =
!hasDimensions && xAxis.type === AxisType.Category;
const categoryAxisValueIndex =
formData.orientation === OrientationType.Horizontal ? 1 : 0;
const getCategoryAxisValue = useCallback(
(data: unknown, name: unknown) => {
if (Array.isArray(data)) {
const categoryAxisValue = data[categoryAxisValueIndex];
if (
typeof categoryAxisValue === 'string' ||
typeof categoryAxisValue === 'number'
) {
return categoryAxisValue;
}
}
if (typeof name === 'string' || typeof name === 'number') {
return name;
}
return undefined;
},
[categoryAxisValueIndex],
);
const eventHandlers: EventHandlers = {
click: props => {
@@ -234,12 +257,15 @@ export default function EchartsTimeseries({
// Cross-filter by dimension (original behavior)
const { seriesName: name } = props;
handleChange(name);
} else if (canCrossFilterByXAxis && props.name != null) {
// Cross-filter by X-axis value when no dimensions (issue #25334).
// Use `name` (the category-axis value) instead of `data[0]`: for
// horizontal bars the data tuple is value-first, so `data[0]` would
// be the metric value rather than the category (issue #41102).
handleXAxisChange(props.name);
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
// Cross-filter by X-axis value when no dimensions (issue #25334)
const categoryAxisValue = getCategoryAxisValue(
props.data,
props.name,
);
if (categoryAxisValue !== undefined) {
handleXAxisChange(categoryAxisValue);
}
}
}, TIMER_DURATION);
},
@@ -321,10 +347,17 @@ export default function EchartsTimeseries({
let crossFilter;
if (hasDimensions) {
crossFilter = getCrossFilterDataMask(seriesName);
} else if (canCrossFilterByXAxis && eventParams.name != null) {
// Use `name` (the category-axis value), not `data[0]`, so horizontal
// bars cross-filter on the category and not the metric (issue #41102).
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
} else if (
canCrossFilterByXAxis &&
eventParams.componentType === 'series'
) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
if (categoryAxisValue !== undefined) {
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
}
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
@@ -336,6 +369,33 @@ export default function EchartsTimeseries({
},
};
const handleXAxisLabelClick = useCallback(
(event: ECElementEvent) => {
const { value } = event;
if (
canCrossFilterByXAxis &&
(typeof value === 'string' || typeof value === 'number')
) {
handleXAxisChange(value);
}
},
[canCrossFilterByXAxis, handleXAxisChange],
);
const categoryAxis =
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
const queryEventHandlers = useMemo(
() => [
{
name: 'click',
query: `${categoryAxis}.category`,
handler: handleXAxisLabelClick,
},
],
[categoryAxis, handleXAxisLabelClick],
);
const zrEventHandlers: EventHandlers = {
dblclick: params => {
// clear single click timer
@@ -377,6 +437,7 @@ export default function EchartsTimeseries({
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
queryEventHandlers={queryEventHandlers}
zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}

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