Compare commits

...

128 Commits

Author SHA1 Message Date
Maxime Beauchemin
fa05dc705f fix(table): fall back to datasource columns for conditional formatting when query results are empty
When a Table chart is filtered to show no results, the conditional
formatting panel was showing no columns because it relied exclusively on
queriesResponse colnames/coltypes. This falls back to datasource schema
columns when query results are unavailable, so users can still configure
conditional formatting rules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:19:09 +00:00
Richard Fogaca Nienkotter
de98fdc37b test(heatmap): restore buildQuery coverage on master (#39329) 2026-04-13 13:50:11 -03:00
Maxime Beauchemin
fa1f12a0b5 fix(explore): replace TableView with virtualized GridTable, add row limit controls, restore sample filters (#39212)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:19:49 -07:00
Maxime Beauchemin
de40b58e10 fix(tests): fix async teardown leak in FiltersConfigModal.test.tsx (#39281)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:48:01 -07:00
Mike Bridge
eea3557f61 fix(dashboard): hide "Filters out of scope" section when empty (#39201)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-04-10 15:42:41 -04:00
Mike Bridge
7a243d329e fix(dashboard): allow filter list to scroll in filter config modal sidebar (#39203)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-04-10 15:42:16 -04:00
Maxime Beauchemin
98146251c4 fix(tests): improve ShareMenuItems test isolation to fix intermittent suite failure (#39280)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:30:38 -07:00
Maxime Beauchemin
0aa8cace1b fix(dataset-editor): fix SQL expression editor extra spaces and height expansion (#39248)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:12:26 -07:00
Maxime Beauchemin
450701ecec fix(SqlLab): improve SQL diff modal — responsive width, padding, tabs, and copy button (#39246)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:11:05 -07:00
Richard Fogaca Nienkotter
e9911fbac4 fix(echarts): prevent tooltip crash during dashboard auto-refresh (#39277)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:36:44 -03:00
Gabriel Torres Ruiz
69c8eef78e fix(ag-grid): jpeg export of ag-grid tables (#38781) 2026-04-10 12:54:59 -03:00
dependabot[bot]
2ff50667e7 chore(deps): bump axios from 1.13.5 to 1.15.0 in /superset-frontend (#39258)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:55:13 +07:00
dependabot[bot]
f1cf274751 chore(deps): bump axios from 1.13.5 to 1.15.0 in /docs (#39259)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:48:35 +07:00
dependabot[bot]
b65396ccd4 chore(deps-dev): bump @types/node from 25.5.2 to 25.6.0 in /superset-websocket (#39262)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:46:57 +07:00
dependabot[bot]
1ad76e847e chore(deps-dev): bump prettier from 3.8.1 to 3.8.2 in /superset-websocket (#39260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:46:34 +07:00
dependabot[bot]
4583ef93a4 chore(deps-dev): bump prettier from 3.8.1 to 3.8.2 in /superset-frontend (#39263)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:40:46 +07:00
dependabot[bot]
f632d2474b chore(deps-dev): bump webpack from 5.105.4 to 5.106.0 in /superset-frontend (#39268)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:40:03 +07:00
Evan Rusackas
b1d69f5b39 docs(api): add Theme API endpoints to OpenAPI spec (#37943)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-10 00:17:06 -07:00
Enzo Martellucci
aba7e6dae4 fix(table): cross-filtering breaks after renaming column labels via Custom SQL (#38858) 2026-04-10 06:02:18 +02:00
Mike Bridge
8bcc90c766 fix(dashboard): Vertical filter bar gradient is extending past the filter bar area (#39204)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-04-09 18:30:47 -07:00
venkateshwaran shanmugham
e39dd1afce fix: implement native browser fullscreen for dashboard charts (#38819)
Signed-off-by: Venkateshwaran Shanmugham <venkateshwaracholan@gmail.com>
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Richard Fogaça <richardfogaca@gmail.com>
Co-authored-by: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com>
2026-04-09 21:49:36 -03:00
Amin Ghadersohi
680cef0ee0 fix(mcp): strip json_metadata and position_json from get_dashboard_info response (#39101) 2026-04-09 17:30:57 -04:00
Amin Ghadersohi
e17cf3c808 fix(mcp): wire up compact schema serialization for search_tools results (#39229) 2026-04-09 17:25:46 -04:00
Shaitan
f49310b8ff fix(sql-lab): apply access check in SqlExecutionResultsCommand (#38952)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:47:15 -04:00
Vitor Avila
c7955a38ef fix: Drill to Detail for Embedded (#39214)
Co-authored-by: Maxime Beauchemin <maximebeauchemin@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:01:48 -03:00
Amin Ghadersohi
68067d7f44 fix(mcp): handle OAuth-authenticated databases in execute_sql (#39166) 2026-04-09 15:47:00 -04:00
Daniel Vaz Gaspar
5815665cc6 feat: role/user CRUD events and login/logout tracking in the action log (#39121)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:55:25 +01:00
Enzo Martellucci
6649f35a0d fix(reports): escape SQL LIKE wildcards in find_by_extra_metadata (#38738)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2026-04-09 12:58:06 +03:00
Mehmet Salih Yavuz
5263abdc60 fix(AlertsReports): untie filters from alerts reports tabs flag (#38722)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:11:43 +03:00
Birk Skyum
c49641538d feat: modernize deck.gl and map plugins with MapLibre/Mapbox dual renderer (#38035)
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
2026-04-08 20:14:59 -04:00
Maxime Beauchemin
d915e4f3ff fix(tags): fix Bulk tag modal dropdown clipping and stale tag cache (#39210)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:28:13 -07:00
Maxime Beauchemin
bad5a35fce fix(explore): constrain Edit Dataset modal height to prevent footer cutoff (#39211)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:19:10 -07:00
Amin Ghadersohi
1bde6f3bfd fix(mcp): resolve null fields in list_datasets, list_databases, and save_sql_query (#39206) 2026-04-08 18:39:56 -04:00
Deadman
4e0890ee1f feat(api): Add filter_dashboard_id parameter to apply dashboard filters to chart/data endpoint (#38638)
Co-authored-by: Matthew Deadman <matthewdeadman@Matthews-MacBook-Pro-2.local>
Co-authored-by: Matthew Deadman <matthewdeadman@matthews-mbp-2.lan>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-04-08 15:32:46 -07:00
Maxime Beauchemin
d63308ca37 fix(frontend): fix loading spinner positioning in Save modal and filters panel (#39205)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: yousoph <sophieyou12@gmail.com>
2026-04-08 13:23:30 -07:00
Maxime Beauchemin
63cceb6a79 refactor(plugins): replace react-icons with antd icons, remove 83MB dependency (#39184)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:21:34 -07:00
Maxime Beauchemin
b8b2bdedf9 fix(ace-editor): style bracket matching to blend with theme (#39182)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:09:14 -07:00
Maxime Beauchemin
d5017e60c3 fix(sqllab): fix table navigator schema list, pin/unpin UX, copy actions, icons, and toolbar colors (#39173)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:06:29 -07:00
Luiz Otavio
2e80f2a473 fix: add template_processor so Jinja gets rendered before SQLGlot parse (#39207) 2026-04-08 16:58:15 -03:00
JUST.in DO IT
4c2dd63464 fix(sqllab): Update style for code viewer container (#39075) 2026-04-08 12:42:06 -07:00
Maxime Beauchemin
62302ad8c3 perf(webpack): reduce watch mode memory usage and fix docker-compose-light env (#39183)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:26:49 -07:00
Maxime Beauchemin
ed659958f3 fix(sqllab): use monospace font for SQL in database error messages (#39181)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:26:25 -07:00
Maxime Beauchemin
36de05fe36 fix(plugin-chart-handlebars): improve CSS sanitization tooltip and hide when not needed (#39180)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:25:54 -07:00
Maxime Beauchemin
a64609f4f3 fix(explore): add left-indentation to control panel hierarchy (#39177)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:25:36 -07:00
Maxime Beauchemin
140f0001f2 fix(sqllab): demote "Save as new" button from primary to secondary (#39179)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:03:44 -07:00
Elizabeth Thompson
587fe4af63 fix(reports): propagate PlaywrightTimeout so execution transitions to ERROR state (#39176)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:00:03 -07:00
Michael S. Molina
3a3a6536b7 fix(explore): Unnecessary scroll bars appearing on charts in Explore (#39160)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-04-08 08:33:20 -03:00
Alexandru Soare
4f695e1b4d fix(filterReports): _generate_native_filter() crashes on null/empty filterValues (#38954) 2026-04-08 13:53:18 +03:00
Maxime Beauchemin
6ba9096870 fix(explore): handle boolean false values correctly in control rendering (#39172)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 18:23:03 -07:00
dependabot[bot]
5106afb07f chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#39145)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:16:28 -07:00
dependabot[bot]
2bd4131636 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39134)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:15:18 -07:00
dependabot[bot]
7e452df1cc chore(deps): bump anthropics/claude-code-action from 1.0.87 to 1.0.89 (#39132)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:14:30 -07:00
dependabot[bot]
a626d06415 chore(deps): bump caniuse-lite from 1.0.30001784 to 1.0.30001786 in /docs (#39128)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:11:13 -07:00
dependabot[bot]
d159edc9a6 chore(deps-dev): bump @swc/core from 1.15.21 to 1.15.24 in /superset-frontend (#39127)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:10:17 -07:00
dependabot[bot]
96fa2cbd2b chore(deps): update @deck.gl/aggregation-layers requirement from ~9.2.9 to ~9.2.11 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#39126)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:09:49 -07:00
dependabot[bot]
9750881193 chore(deps-dev): bump @types/node from 25.5.0 to 25.5.2 in /superset-websocket (#39125)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:09:25 -07:00
dependabot[bot]
3db92021c7 chore(deps-dev): bump eslint from 10.1.0 to 10.2.0 in /superset-websocket (#39123)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:08:46 -07:00
dependabot[bot]
5ccfc530b2 chore(deps): bump geolib from 3.3.4 to 3.3.14 in /superset-frontend (#39092)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:48:08 -07:00
Amin Ghadersohi
5f9fc31ae2 feat(mcp): add get_chart_type_schema tool for on-demand schema discovery (#39142) 2026-04-07 12:07:45 -04:00
dependabot[bot]
8e811de564 chore(deps): bump hot-shots from 14.2.0 to 14.3.1 in /superset-websocket (#39147)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 22:43:50 +07:00
dependabot[bot]
027de6339b chore(deps-dev): bump jsdom from 29.0.1 to 29.0.2 in /superset-frontend (#39155)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 21:39:04 +07:00
Amin Ghadersohi
bf9aff19b5 fix(mcp): compress chart config schemas to reduce search_tools token usage (#39018) 2026-04-06 19:52:03 -04:00
SBIN2010
b05764d070 feat: Add currencies controls in country map (#39016) 2026-04-06 23:20:03 +03:00
Amin Ghadersohi
7be2acb2f3 fix(mcp): add description and certification fields to default list tool columns (#39017) 2026-04-06 13:37:52 -04:00
Amin Ghadersohi
83ad1eca26 fix(mcp): add dynamic response truncation for oversized info tool responses (#39107) 2026-04-06 12:36:03 -04:00
Amin Ghadersohi
92747246fc fix(mcp): remove JWT ValueError g.user fallback in auth layer (#39106) 2026-04-06 12:35:46 -04:00
Amin Ghadersohi
7380a59ab8 fix(mcp): fix form_data null, dataset URL, ASCII preview, and chart rename (#39109) 2026-04-06 12:34:26 -04:00
Ville Brofeldt
e56f8cc4fb fix(security_manager): custom auth_view issue (#39098) 2026-04-06 09:04:59 -07:00
Ville Brofeldt
7c79b9ab61 fix(migrations): check pre-existing foreign keys on create util (#39099) 2026-04-06 09:04:22 -07:00
Maxime Beauchemin
a62be684a0 feat(mcp): add database connection listing and info tools (#39111)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-04-06 11:34:10 -04:00
Michael S. Molina
a3dfbd7bff fix(deps): revert simple-zstd from 2.1.0 back to 1.4.2 (#39139)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:28:28 -03:00
Sam Firke
12eb40db01 fix(SQL Lab): handle columns without names (#38986) 2026-04-06 10:09:16 -04:00
dependabot[bot]
d796543f5a chore(deps): update @deck.gl/react requirement from ~9.2.9 to ~9.2.11 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#39033)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:07:42 -07:00
dependabot[bot]
e5ae626433 chore(deps): bump dawidd6/action-download-artifact from 19 to 20 (#39081)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:06:06 -07:00
dependabot[bot]
8195574345 chore(deps): bump anthropics/claude-code-action from 1.0.85 to 1.0.87 (#39083)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:04:43 -07:00
dependabot[bot]
6b029997d9 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39087)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:04:04 -07:00
dependabot[bot]
7a64483e6b chore(deps-dev): bump @swc/plugin-emotion from 14.7.0 to 14.8.0 in /superset-frontend (#39088)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:03:44 -07:00
dependabot[bot]
e424b55036 chore(deps-dev): bump babel-loader from 10.1.0 to 10.1.1 in /superset-frontend (#39090)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:02:57 -07:00
dependabot[bot]
613e6d6cde chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#39093)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:02:11 -07:00
Amin Ghadersohi
b3a402d936 fix(mcp): handle stale SSL connections, heatmap duplicate labels, and session rollback (#39015) 2026-04-03 16:07:29 -04:00
JUST.in DO IT
c7d175b842 fix(dashboard): remove opacity on filter dropdown (#39074) 2026-04-03 09:31:23 -07:00
Amin Ghadersohi
851bbeea48 fix(mcp): improve execute_sql response-too-large error to suggest limit parameter (#39003) 2026-04-03 10:57:31 -04:00
dependabot[bot]
c5bce756f0 chore(deps): bump yeoman-generator from 7.5.1 to 8.1.2 in /superset-frontend (#38671)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:13:20 -07:00
dependabot[bot]
3239f058c8 chore(deps-dev): bump baseline-browser-mapping from 2.10.10 to 2.10.13 in /superset-frontend (#39044)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:10:35 -07:00
dependabot[bot]
7e0c634c3a chore(deps-dev): bump typescript-eslint from 8.56.1 to 8.58.0 in /superset-websocket (#38997)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:46:19 -07:00
dependabot[bot]
a9ced5c881 chore(deps): bump lodash-es from 4.17.23 to 4.18.1 in /superset-frontend (#39026)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:45:55 -07:00
dependabot[bot]
ace5f9d8c2 chore(deps): bump lodash from 4.17.23 to 4.18.1 in /superset-frontend/cypress-base (#39027)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:45:28 -07:00
dependabot[bot]
0452d1515a chore(deps-dev): bump @babel/preset-env from 7.29.0 to 7.29.2 in /superset-frontend (#39028)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:44:57 -07:00
dependabot[bot]
0330fdeb00 chore(deps-dev): bump ts-jest from 29.4.6 to 29.4.9 in /superset-websocket (#39029)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:44:32 -07:00
dependabot[bot]
f2ff24d811 chore(deps): bump @ant-design/icons from 5.6.1 to 6.1.1 in /superset-frontend (#39050)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:42:10 -07:00
dependabot[bot]
c51132f824 chore(deps): bump aws-actions/amazon-ecr-login from 2.1.1 to 2.1.2 (#39042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:43:40 +07:00
dependabot[bot]
b4cb815ebf chore(deps): bump anthropics/claude-code-action from 1.0.83 to 1.0.85 (#39037)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:43:05 +07:00
dependabot[bot]
08d1ddd9fb chore(deps-dev): bump mini-css-extract-plugin from 2.10.1 to 2.10.2 in /superset-frontend (#39054)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:33:50 +07:00
dependabot[bot]
23ac4cb3a4 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39051)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:32:55 +07:00
Joe Li
5662ecab15 chore(tests): promote Playwright experimental tests to stable (#38924)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:00:06 -07:00
Joe Li
9e27d682f6 test(alerts/reports): close backend and frontend test coverage gaps (#38591)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 11:55:24 -07:00
Joe Li
f0fcdcc76a chore: package bumps (#39014) 2026-04-02 11:53:07 -07:00
Kamil Gabryjelski
135e0f8099 fix(mcp): Created dashboard should be in draft state by default (#39011) 2026-04-02 19:28:51 +02:00
Geidō
25eea295f6 fix(reports): log exception traceback in _get_csv_data (#39069)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 18:37:57 +02:00
dependabot[bot]
c372f5980c chore(deps): bump lodash from 4.17.23 to 4.18.1 in /superset-frontend (#39043)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-04-02 23:36:36 +07:00
dependabot[bot]
3802acb1e0 chore(deps-dev): bump jest-html-reporter from 4.3.0 to 4.4.0 in /superset-frontend (#39053)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:08:48 +07:00
dependabot[bot]
bdb0030cf8 chore(deps-dev): bump @swc/core from 1.15.18 to 1.15.21 in /superset-frontend (#39045)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:07:44 +07:00
dependabot[bot]
87f0540acd chore(deps-dev): bump ts-jest from 29.4.6 to 29.4.9 in /superset-frontend (#39049)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:06:54 +07:00
dependabot[bot]
985d7b6a79 chore(deps-dev): bump @types/node from 25.3.5 to 25.5.0 in /superset-frontend (#39055)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:06:13 +07:00
dependabot[bot]
59f92f979a chore(deps-dev): bump eslint-plugin-testing-library from 7.16.1 to 7.16.2 in /superset-frontend (#39056)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:03:50 +07:00
dependabot[bot]
5cc286e383 chore(deps-dev): bump @playwright/test from 1.58.2 to 1.59.1 in /superset-frontend (#39057)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:59:29 +07:00
dependabot[bot]
26f4a5acad chore(deps): bump azure/setup-helm from 4.3.1 to 5.0.0 (#38815)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-02 01:28:56 -04:00
dependabot[bot]
fdd08d3b70 chore(deps): bump ws from 8.19.0 to 8.20.0 in /superset-websocket (#38994)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 01:28:52 -04:00
dependabot[bot]
1aac6c9474 chore(deps): update @deck.gl/react requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38150)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 14:38:12 -07:00
dependabot[bot]
7acb0c6d05 chore(deps-dev): bump @types/node from 25.3.3 to 25.3.5 in /superset-frontend (#38510)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 14:27:54 -07:00
dependabot[bot]
00eb86d03f chore(deps-dev): bump @swc/plugin-emotion from 14.6.0 to 14.7.0 in /superset-frontend (#38333)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 14:27:11 -07:00
dependabot[bot]
1d0e836a29 chore(deps): update @deck.gl/extensions requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 13:30:26 -07:00
dependabot[bot]
ec6640b188 chore(deps-dev): update fs-extra requirement from ^11.3.3 to ^11.3.4 in /superset-frontend/packages/generator-superset (#38383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 13:29:47 -07:00
Rayan Salhab
ff3b8d8398 fix(ace-editor): fix cursor misalignment in markdown editor (#38928) 2026-04-01 12:03:28 -07:00
Michael S. Molina
022342839a fix(echarts): fix stacked horizontal bar chart clipping and duplicate x-axis labels (#39012) 2026-04-01 15:50:08 -03:00
Michael S. Molina
38f0dc74f7 fix(dataset-editor): improve modal layout and fix Settings tab horizontal scroll (#39009)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-04-01 15:36:17 -03:00
Amin Ghadersohi
0bae05d4a9 fix(mcp): handle table chart raw mode in query builders and sanitize dashboard titles (#38990) 2026-04-01 13:42:59 -04:00
dependabot[bot]
1bb41a6e60 chore(deps): bump anthropics/claude-code-action from 1.0.82 to 1.0.83 (#39001)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 10:20:18 -07:00
dependabot[bot]
4423134739 chore(deps): bump ioredis from 5.10.0 to 5.10.1 in /superset-websocket (#38993)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 10:19:26 -07:00
Amin Ghadersohi
190f1a59c5 fix(mcp): fix dashboard owners Pydantic crash and preserve chart preview filters (#38987) 2026-04-01 13:18:28 -04:00
dependabot[bot]
5f99d613a0 chore(deps): bump markdown-to-jsx from 9.7.6 to 9.7.8 in /superset-frontend (#38507)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 09:34:47 -07:00
dependabot[bot]
6adc816805 chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#38438)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 09:34:01 -07:00
dependabot[bot]
aa97679327 chore(deps): update @deck.gl/aggregation-layers requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38148)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 09:32:38 -07:00
Michael S. Molina
94d8735d4b fix(query): pass datasource table to template processor for schema-aware Jinja rendering (#38984)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 13:08:12 -03:00
dependabot[bot]
64c8d652e1 chore(deps-dev): bump @types/node from 25.3.5 to 25.5.0 in /superset-websocket (#38995)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:48:45 +07:00
dependabot[bot]
d30c5b4eee chore(deps): bump caniuse-lite from 1.0.30001782 to 1.0.30001784 in /docs (#38998)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:37:29 +07:00
dependabot[bot]
8ed75787cb chore(deps-dev): bump jsdom from 28.1.0 to 29.0.1 in /superset-frontend (#38999)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 17:39:08 +07:00
michaelkremmel
4ee391e0d7 docs(db): Update crate.py with new Homepage URL (#39002) 2026-04-01 17:37:51 +07:00
508 changed files with 24353 additions and 7765 deletions

View File

@@ -76,7 +76,7 @@ jobs:
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # beta
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"

View File

@@ -58,7 +58,7 @@ jobs:
- name: Login to Amazon ECR
if: steps.describe-services.outputs.active == 'true'
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Delete ECR image tag
if: steps.describe-services.outputs.active == 'true'

View File

@@ -199,7 +199,7 @@ jobs:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Load, tag and push image to ECR
id: push-image
@@ -235,7 +235,7 @@ jobs:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Check target image exists in ECR
id: check-image

View File

@@ -70,7 +70,7 @@ jobs:
yarn install --check-cache
- name: Download database diagnostics (if triggered by integration tests)
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
@@ -79,7 +79,7 @@ jobs:
path: docs/src/data/
- name: Try to download latest diagnostics (for push/dispatch triggers)
if: github.event_name != 'workflow_run'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml

View File

@@ -111,7 +111,7 @@ jobs:
run: |
yarn install --check-cache
- name: Download database diagnostics from integration tests
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
workflow: superset-python-integrationtest.yml
run_id: ${{ github.event.workflow_run.id }}

View File

@@ -23,7 +23,7 @@ jobs:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.16.4

View File

@@ -42,7 +42,7 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.5.4

View File

@@ -115,6 +115,10 @@ services:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-}
GITHUB_SHA: ${GITHUB_SHA:-}
@@ -137,6 +141,10 @@ services:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
healthcheck:
disable: true
@@ -157,6 +165,7 @@ services:
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
NPM_RUN_PRUNE: false
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
DISABLE_TS_CHECKER: "${DISABLE_TS_CHECKER:-true}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container

View File

@@ -80,7 +80,7 @@ case "${1}" in
;;
app)
echo "Starting web app (using development server)..."
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
;;
app-gunicorn)
echo "Starting web app..."

View File

@@ -412,7 +412,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security roles](/developer-docs/api/get-security-roles) | `/api/v1/security/roles/` |
| `POST` | [Create security roles](/developer-docs/api/create-security-roles) | `/api/v1/security/roles/` |
| `GET` | [Get security roles info](/developer-docs/api/get-security-roles-info) | `/api/v1/security/roles/_info` |
| `GET` | [Get security roles info](/developer-docs/api/get-security-roles-info) | `/api/v1/security/roles/_info` |
| `DELETE` | [Delete security roles by pk](/developer-docs/api/delete-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `GET` | [Get security roles by pk](/developer-docs/api/get-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `PUT` | [Update security roles by pk](/developer-docs/api/update-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
@@ -430,7 +430,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security users](/developer-docs/api/get-security-users) | `/api/v1/security/users/` |
| `POST` | [Create security users](/developer-docs/api/create-security-users) | `/api/v1/security/users/` |
| `GET` | [Get security users info](/developer-docs/api/get-security-users-info) | `/api/v1/security/users/_info` |
| `GET` | [Get security users info](/developer-docs/api/get-security-users-info) | `/api/v1/security/users/_info` |
| `DELETE` | [Delete security users by pk](/developer-docs/api/delete-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `GET` | [Get security users by pk](/developer-docs/api/get-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `PUT` | [Update security users by pk](/developer-docs/api/update-security-users-by-pk) | `/api/v1/security/users/{pk}` |
@@ -443,7 +443,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | [Get security permissions](/developer-docs/api/get-security-permissions) | `/api/v1/security/permissions/` |
| `GET` | [Get security permissions info](/developer-docs/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` |
| `GET` | [Get security permissions info](/developer-docs/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` |
| `GET` | [Get security permissions by pk](/developer-docs/api/get-security-permissions-by-pk) | `/api/v1/security/permissions/{pk}` |
</details>
@@ -455,7 +455,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security resources](/developer-docs/api/get-security-resources) | `/api/v1/security/resources/` |
| `POST` | [Create security resources](/developer-docs/api/create-security-resources) | `/api/v1/security/resources/` |
| `GET` | [Get security resources info](/developer-docs/api/get-security-resources-info) | `/api/v1/security/resources/_info` |
| `GET` | [Get security resources info](/developer-docs/api/get-security-resources-info) | `/api/v1/security/resources/_info` |
| `DELETE` | [Delete security resources by pk](/developer-docs/api/delete-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `GET` | [Get security resources by pk](/developer-docs/api/get-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `PUT` | [Update security resources by pk](/developer-docs/api/update-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
@@ -469,7 +469,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security permissions resources](/developer-docs/api/get-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `POST` | [Create security permissions resources](/developer-docs/api/create-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `GET` | [Get security permissions resources info](/developer-docs/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` |
| `GET` | [Get security permissions resources info](/developer-docs/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` |
| `DELETE` | [Delete security permissions resources by pk](/developer-docs/api/delete-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `GET` | [Get security permissions resources by pk](/developer-docs/api/get-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `PUT` | [Update security permissions resources by pk](/developer-docs/api/update-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
@@ -578,7 +578,29 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | [Get api by version openapi](/developer-docs/api/get-api-by-version-openapi) | `/api/{version}/_openapi` |
| `GET` | [Get api by version openapi](/developer-docs/api/get-api-by-version-openapi) | `/api/{version}/_openapi` |
</details>
<details>
<summary><strong>Themes</strong> (14 endpoints) — Manage UI themes for customizing Superset's appearance.</summary>
| Method | Endpoint | Description |
|--------|----------|-------------|
| `DELETE` | [Bulk delete themes](/developer-docs/api/bulk-delete-themes) | `/api/v1/theme/` |
| `GET` | [Get a list of themes](/developer-docs/api/get-a-list-of-themes) | `/api/v1/theme/` |
| `POST` | [Create a theme](/developer-docs/api/create-a-theme) | `/api/v1/theme/` |
| `GET` | [Get metadata information about this API resource (theme-info)](/developer-docs/api/get-metadata-information-about-this-api-resource-theme-info) | `/api/v1/theme/_info` |
| `DELETE` | [Delete a theme](/developer-docs/api/delete-a-theme) | `/api/v1/theme/{pk}` |
| `GET` | [Get a theme](/developer-docs/api/get-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Update a theme](/developer-docs/api/update-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Set a theme as the system dark theme](/developer-docs/api/set-a-theme-as-the-system-dark-theme) | `/api/v1/theme/{pk}/set_system_dark` |
| `PUT` | [Set a theme as the system default theme](/developer-docs/api/set-a-theme-as-the-system-default-theme) | `/api/v1/theme/{pk}/set_system_default` |
| `GET` | [Download multiple themes as YAML files](/developer-docs/api/download-multiple-themes-as-yaml-files) | `/api/v1/theme/export/` |
| `POST` | [Import themes from a ZIP file](/developer-docs/api/import-themes-from-a-zip-file) | `/api/v1/theme/import/` |
| `GET` | [Get related fields data (theme-related-column-name)](/developer-docs/api/get-related-fields-data-theme-related-column-name) | `/api/v1/theme/related/{column_name}` |
| `DELETE` | [Clear the system dark theme](/developer-docs/api/clear-the-system-dark-theme) | `/api/v1/theme/unset_system_dark` |
| `DELETE` | [Clear the system default theme](/developer-docs/api/clear-the-system-default-theme) | `/api/v1/theme/unset_system_default` |
</details>

View File

@@ -70,7 +70,7 @@
"@swc/core": "^1.15.21",
"antd": "^6.3.5",
"baseline-browser-mapping": "^2.10.13",
"caniuse-lite": "^1.0.30001782",
"caniuse-lite": "^1.0.30001786",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
"js-yaml": "^4.1.1",

View File

@@ -129,6 +129,30 @@ def add_missing_schemas(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]
}
fixed.append("DashboardColorsConfigUpdateSchema")
# DashboardChartCustomizationsConfigUpdateSchema (dashboards/schemas.py)
if "DashboardChartCustomizationsConfigUpdateSchema" not in schemas:
schemas["DashboardChartCustomizationsConfigUpdateSchema"] = {
"type": "object",
"properties": {
"deleted": {
"type": "array",
"items": {"type": "string"},
"description": "List of deleted chart customization IDs.",
},
"modified": {
"type": "array",
"items": {"type": "object"},
"description": "List of modified chart customizations.",
},
"reordered": {
"type": "array",
"items": {"type": "string"},
"description": "List of chart customization IDs in new order.",
},
},
}
fixed.append("DashboardChartCustomizationsConfigUpdateSchema")
# FormatQueryPayloadSchema - based on superset/sqllab/schemas.py
if "FormatQueryPayloadSchema" not in schemas:
schemas["FormatQueryPayloadSchema"] = {
@@ -295,6 +319,7 @@ TAG_DESCRIPTIONS = {
"Security Roles": "Manage security roles and their permissions.",
"Security Users": "Manage user accounts.",
"Tags": "Organize assets with tags.",
"Themes": "Manage UI themes for customizing Superset's appearance.",
"User": "User profile and preferences.",
}

File diff suppressed because it is too large Load Diff

View File

@@ -5743,13 +5743,13 @@ available-typed-arrays@^1.0.7:
possible-typed-array-names "^1.0.0"
axios@^1.12.2:
version "1.13.5"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43"
integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==
version "1.15.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f"
integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==
dependencies:
follow-redirects "^1.15.11"
form-data "^4.0.5"
proxy-from-env "^1.1.0"
proxy-from-env "^2.1.0"
babel-loader@^9.2.1:
version "9.2.1"
@@ -6067,10 +6067,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001782:
version "1.0.30001782"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz#f2b8617f998bc134701c54ce9748af44f646e062"
integrity sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001786:
version "1.0.30001786"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz#586120fc73f3c7ee82152f76acd0c37e04acefbb"
integrity sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==
ccount@^2.0.0:
version "2.0.1"
@@ -12643,10 +12643,10 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
proxy-from-env@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
punycode@^1.4.1:
version "1.4.1"

View File

@@ -48,7 +48,7 @@ dependencies = [
"cryptography>=42.0.4, <47.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.0.2,<6",
"flask-appbuilder>=5.2.1, <6.0.0",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",

View File

@@ -86,7 +86,7 @@ cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.0.0
# via apache-superset (pyproject.toml)
cryptography==46.0.5
cryptography==46.0.6
# via
# apache-superset (pyproject.toml)
# paramiko
@@ -120,7 +120,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.2.0
flask-appbuilder==5.2.1
# via
# apache-superset (pyproject.toml)
# apache-superset-core
@@ -209,7 +209,7 @@ mako==1.3.10
# via
# apache-superset (pyproject.toml)
# alembic
markdown==3.8
markdown==3.8.1
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
@@ -279,7 +279,7 @@ parsedatetime==2.6
# via apache-superset (pyproject.toml)
pgsanity==0.2.9
# via apache-superset (pyproject.toml)
pillow==11.3.0
pillow==12.1.1
# via apache-superset (pyproject.toml)
platformdirs==4.3.8
# via requests-cache
@@ -293,7 +293,7 @@ prompt-toolkit==3.0.51
# via click-repl
pyarrow==16.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.2
pyasn1==0.6.3
# via
# pyasn1-modules
# rsa
@@ -309,9 +309,9 @@ pydantic-core==2.33.2
# via pydantic
pygeohash==3.2.2
# via apache-superset (pyproject.toml)
pygments==2.19.1
pygments==2.20.0
# via rich
pyjwt==2.10.1
pyjwt==2.12.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder

View File

@@ -178,7 +178,7 @@ croniter==6.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
cryptography==46.0.5
cryptography==46.0.6
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -259,7 +259,7 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==5.2.0
flask-appbuilder==5.2.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -508,7 +508,7 @@ mako==1.3.10
# -c requirements/base-constraint.txt
# alembic
# apache-superset
markdown==3.8
markdown==3.8.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -655,7 +655,7 @@ pgsanity==0.2.9
# via
# -c requirements/base-constraint.txt
# apache-superset
pillow==11.3.0
pillow==12.1.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -716,7 +716,7 @@ pyarrow==16.1.0
# apache-superset
# db-dtypes
# pandas-gbq
pyasn1==0.6.2
pyasn1==0.6.3
# via
# -c requirements/base-constraint.txt
# pyasn1-modules
@@ -756,7 +756,7 @@ pygeohash==3.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
pygments==2.19.1
pygments==2.20.0
# via
# -c requirements/base-constraint.txt
# rich
@@ -764,7 +764,7 @@ pyhive==0.7.0
# via apache-superset
pyinstrument==4.4.0
# via apache-superset
pyjwt==2.10.1
pyjwt==2.12.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -1,171 +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 { CHART_LIST } from 'cypress/utils/urls';
import { setGridMode, toggleBulkSelect } from 'cypress/utils';
import {
setFilter,
interceptBulkDelete,
interceptUpdate,
interceptDelete,
interceptFiltering,
interceptFavoriteStatus,
} from '../explore/utils';
function orderAlphabetical() {
setFilter('Sort', 'Alphabetical');
}
function openProperties() {
cy.get('[aria-label="more"]').eq(0).click();
cy.getBySel('chart-list-edit-option').click();
}
function openMenu() {
cy.get('[aria-label="more"]').eq(0).click();
}
function confirmDelete() {
cy.getBySel('delete-modal-input').type('DELETE');
cy.getBySel('modal-confirm-button').click();
}
function visitChartList() {
interceptFiltering();
interceptFavoriteStatus();
cy.visit(CHART_LIST);
cy.wait('@filtering');
cy.wait('@favoriteStatus');
}
describe('Charts list', () => {
describe('common actions', () => {
beforeEach(() => {
visitChartList();
});
it('should bulk delete correctly', () => {
cy.createSampleCharts([0, 1, 2, 3]);
interceptBulkDelete();
toggleBulkSelect();
// bulk deletes in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('skeleton-card').should('not.exist');
cy.getBySel('styled-card').contains('1 - Sample chart').click();
cy.getBySel('styled-card').contains('2 - Sample chart').click();
cy.getBySel('bulk-select-action').contains('Delete').click();
confirmDelete();
cy.wait('@bulkDelete');
cy.getBySel('styled-card')
.eq(1)
.should('not.contain', '1 - Sample chart');
cy.getBySel('styled-card')
.eq(2)
.should('not.contain', '2 - Sample chart');
// bulk deletes in list-view
setGridMode('list');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').contains('3 - Sample chart').should('exist');
cy.getBySel('table-row').contains('4 - Sample chart').should('exist');
cy.get('[data-test="table-row"] input[type="checkbox"]').eq(0).click();
cy.get('[data-test="table-row"] input[type="checkbox"]').eq(1).click();
cy.getBySel('bulk-select-action').eq(0).contains('Delete').click();
confirmDelete();
cy.wait('@bulkDelete');
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').eq(0).should('not.contain', '3 - Sample chart');
cy.getBySel('table-row').eq(1).should('not.contain', '4 - Sample chart');
});
it('should delete correctly in card mode', () => {
cy.createSampleCharts([0, 1]);
interceptDelete();
// deletes in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('styled-card').contains('1 - Sample chart');
openMenu();
cy.getBySel('chart-list-delete-option').click();
confirmDelete();
cy.wait('@delete');
cy.getBySel('styled-card')
.contains('1 - Sample chart')
.should('not.exist');
});
it('should delete correctly in list mode', () => {
cy.createSampleCharts([2, 3]);
interceptDelete();
cy.getBySel('sort-header').contains('Name').click();
// Modal closes immediately without this
cy.wait(2000);
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');
cy.getBySel('delete').eq(0).click();
confirmDelete();
cy.wait('@delete');
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').eq(0).should('not.contain', '3 - Sample chart');
});
it('should edit correctly', () => {
cy.createSampleCharts([0]);
interceptUpdate();
// edits in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('skeleton-card').should('not.exist');
cy.getBySel('styled-card').eq(0).contains('1 - Sample chart');
// change title
openProperties();
cy.getBySel('properties-modal-name-input').type(' | EDITED');
cy.get('button:contains("Save")').click();
cy.wait('@update');
cy.getBySel('styled-card').eq(0).contains('1 - Sample chart | EDITED');
// edits in list-view
setGridMode('list');
// Wait for list view to fully render after mode change
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').should('be.visible');
// Target the specific row by chart title to avoid flakiness from row ordering
cy.getBySel('table-row')
.contains('1 - Sample chart | EDITED')
.parents('[data-test="table-row"]')
.find('[data-test="edit-alt"]')
.click();
cy.getBySel('properties-modal-name-input').clear();
cy.getBySel('properties-modal-name-input').type('1 - Sample chart');
cy.get('button:contains("Save")').click();
cy.wait('@update');
cy.getBySel('table-row').contains('1 - Sample chart').should('exist');
});
});
});

View File

@@ -1,42 +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 { DATASET_LIST_PATH } from 'cypress/utils/urls';
describe('Dataset list', () => {
before(() => {
cy.visit(DATASET_LIST_PATH);
});
xit('should open Explore on dataset name click', () => {
cy.intercept('**/api/v1/explore/**').as('explore');
cy.get('[data-test="listview-table"] [data-test="internal-link"]')
.contains('birth_names')
.click();
cy.wait('@explore');
cy.get('[data-test="datasource-control"] .title-select').contains(
'birth_names',
);
cy.get('.metric-option-label').first().contains('COUNT(*)');
cy.get('.column-option-label').first().contains('ds');
cy.get('[data-test="fast-viz-switcher"] > div:not([role="button"]')
.contains('Table')
.should('be.visible');
});
});

View File

@@ -23,18 +23,6 @@ export function interceptFiltering() {
cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering');
}
export function interceptBulkDelete() {
cy.intercept('DELETE', `**/api/v1/chart/?q=*`).as('bulkDelete');
}
export function interceptDelete() {
cy.intercept('DELETE', `**/api/v1/chart/*`).as('delete');
}
export function interceptFavoriteStatus() {
cy.intercept('GET', '**/api/v1/chart/favorite_status/*').as('favoriteStatus');
}
export function interceptUpdate() {
cy.intercept('PUT', `**/api/v1/chart/*`).as('update');
}
@@ -43,32 +31,13 @@ export const interceptV1ChartData = (alias = 'v1Data') => {
cy.intercept('**/api/v1/chart/data*').as(alias);
};
export function interceptExploreJson(alias = 'getJson') {
cy.intercept('POST', `**/superset/explore_json/**`).as(alias);
}
export const interceptFormDataKey = () => {
cy.intercept('POST', '**/api/v1/explore/form_data').as('formDataKey');
};
export function interceptExploreGet() {
function interceptExploreGet() {
cy.intercept({
method: 'GET',
url: /.*\/api\/v1\/explore\/\?(form_data_key|dashboard_page_id|slice_id)=.*/,
}).as('getExplore');
}
export function setFilter(filter: string, option: string) {
interceptFiltering();
cy.get(`[aria-label^="${filter}"]`).first().click();
cy.get(`.ant-select-item-option[title="${option}"]`).first().click({
force: true,
});
cy.wait('@filtering');
}
export function saveChartToDashboard(chartName: string, dashboardName: string) {
interceptDashboardGet();
interceptUpdate();

View File

@@ -25,28 +25,6 @@ export interface ChartSpec {
viz: string;
}
const viewTypeIcons = {
card: 'appstore',
list: 'unordered-list',
};
export function setGridMode(type: 'card' | 'list') {
const icon = viewTypeIcons[type];
cy.get(`[aria-label="${icon}"]`).click();
}
export function toggleBulkSelect() {
cy.getBySel('bulk-select').click();
}
export function clearAllInputs() {
cy.get('body').then($body => {
if ($body.find('.ant-select-clear').length) {
cy.get('.ant-select-clear').click({ multiple: true, force: true });
}
});
}
const toSlicelike = ($chart: JQuery<HTMLElement>): Slice => {
const chartId = $chart.attr('data-test-chart-id');
const vizType = $chart.attr('data-test-viz-type');

View File

@@ -25,8 +25,3 @@ export const SUPPORTED_CHARTS_DASHBOARD =
'/superset/dashboard/supported_charts_dash/';
export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/';
export const DATABASE_LIST = '/databaseview/list';
export const DATASET_LIST_PATH = 'tablemodelview/list';
export const ALERT_LIST = '/alert/list/';
export const REPORT_LIST = '/report/list/';
export const LOGIN = '/login/';
export const REGISTER = '/register/';

View File

@@ -5809,9 +5809,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
@@ -13072,9 +13072,9 @@
}
},
"lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"lodash.clonedeep": {
"version": "4.5.0",

File diff suppressed because it is too large Load Diff

View File

@@ -128,17 +128,17 @@
"@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord",
"@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map",
"@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon",
"@superset-ui/legacy-plugin-chart-map-box": "file:./plugins/legacy-plugin-chart-map-box",
"@superset-ui/legacy-plugin-chart-paired-t-test": "file:./plugins/legacy-plugin-chart-paired-t-test",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "file:./plugins/legacy-plugin-chart-parallel-coordinates",
"@superset-ui/legacy-plugin-chart-partition": "file:./plugins/legacy-plugin-chart-partition",
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose",
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
"@superset-ui/legacy-preset-chart-deckgl": "file:./plugins/legacy-preset-chart-deckgl",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
@@ -169,7 +169,7 @@
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.4",
"fuse.js": "^7.1.0",
"geolib": "^3.3.4",
"geolib": "^3.3.14",
"geostyler": "^18.3.1",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.4.1",
@@ -182,9 +182,9 @@
"js-levenshtein": "^1.1.6",
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"mapbox-gl": "^3.20.0",
"markdown-to-jsx": "^9.7.6",
"markdown-to-jsx": "^9.7.13",
"match-sorter": "^8.2.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -223,7 +223,7 @@
"redux-undo": "^1.0.0-beta9-9-7",
"rison": "^0.1.1",
"scroll-into-view-if-needed": "^3.1.0",
"simple-zstd": "^2.1.0",
"simple-zstd": "^1.4.2",
"stream-browserify": "^3.0.0",
"tinycolor2": "^1.4.2",
"urijs": "^1.19.8",
@@ -244,7 +244,7 @@
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
@@ -256,7 +256,7 @@
"@emotion/jest": "^11.14.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.59.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-actions": "^8.6.17",
"@storybook/addon-controls": "^8.6.17",
@@ -270,8 +270,8 @@
"@storybook/test": "^8.6.15",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.18",
"@swc/plugin-emotion": "^14.6.0",
"@swc/core": "^1.15.24",
"@swc/plugin-emotion": "^14.8.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -284,7 +284,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.3.3",
"@types/node": "^25.5.0",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -301,11 +301,11 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
"babel-loader": "^10.1.0",
"babel-loader": "^10.1.1",
"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.10",
"baseline-browser-mapping": "^2.10.13",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -327,7 +327,7 @@
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.2",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.1",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
@@ -337,17 +337,17 @@
"imports-loader": "^5.0.0",
"jest": "^30.3.0",
"jest-environment-jsdom": "^29.7.0",
"jest-html-reporter": "^4.3.0",
"jest-html-reporter": "^4.4.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^28.1.0",
"jsdom": "^29.0.2",
"lerna": "^9.0.4",
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.1",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.56.0",
"po2json": "^0.4.5",
"prettier": "3.8.1",
"prettier": "3.8.2",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-refresh": "^0.18.0",
@@ -361,14 +361,14 @@
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.4.0",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.6",
"ts-jest": "^29.4.9",
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.4",
"webpack": "^5.105.4",
"webpack": "^5.106.0",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.3",

View File

@@ -29,8 +29,8 @@
},
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.17.23",
"yeoman-generator": "^7.5.1",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.1.2",
"yosay": "^3.0.0"
},
"devDependencies": {

View File

@@ -75,7 +75,7 @@
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"typescript": "^5.0.0",
@@ -102,7 +102,7 @@
"react-dom": "^17.0.2",
"react-loadable": "^5.5.0",
"tinycolor2": "*",
"lodash": "^4.17.21",
"lodash": "^4.18.1",
"antd": "^5.26.0",
"jed": "^1.1.1"
},

View File

@@ -26,11 +26,11 @@
"dependencies": {
"@apache-superset/core": "*",
"@types/react": "*",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"tinycolor2": "*"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.4.1",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -21,7 +21,11 @@ import { styled, css } from '@apache-superset/core/theme';
export const ControlSubSectionHeader = styled.div`
${({ theme }) => css`
font-weight: ${theme.fontWeightStrong};
margin-top: ${theme.sizeUnit * 3}px;
margin-bottom: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeSM}px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${theme.colorTextSecondary};
`}
`;

View File

@@ -24,39 +24,40 @@
"lib"
],
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.29.2",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.43.6",
"ag-grid-community": "35.0.1",
"ag-grid-react": "35.0.1",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"csstype": "^3.2.3",
"core-js": "^3.49.0",
"csstype": "^3.2.3",
"d3-format": "^3.1.2",
"dayjs": "^1.11.20",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"math-expression-evaluator": "^2.0.7",
"pretty-ms": "^9.3.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-js-cron": "^5.2.0",
"react-draggable": "^4.5.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"react-error-boundary": "6.0.0",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -64,7 +65,6 @@
"reselect": "^5.1.1",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"@visx/responsive": "^3.12.0",
"xss": "^1.0.15"
},
"devDependencies": {
@@ -74,12 +74,12 @@
"@types/d3-scale": "^2.1.1",
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/react-table": "^7.7.20",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.3",
"@types/node": "^25.5.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
"fetch-mock": "^12.6.0",
@@ -88,7 +88,6 @@
"timezone-mock": "1.4.0"
},
"peerDependencies": {
"antd": "^5.26.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
@@ -101,6 +100,7 @@
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"antd": "^5.26.0",
"nanoid": "^5.0.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@@ -41,6 +41,7 @@ export enum VizType {
LegacyBubble = 'bubble',
Line = 'echarts_timeseries_line',
MapBox = 'mapbox',
PointClusterMap = 'point_cluster_map',
MixedTimeseries = 'mixed_timeseries',
PairedTTest = 'paired_ttest',
ParallelCoordinates = 'para',

View File

@@ -283,6 +283,16 @@ export function AsyncAceEditor(
color: ${token.colorText} !important;
}
/* Fix cursor misalignment by ensuring consistent font-family */
.ace_editor .ace_content {
font-family: ${editorFontFamily} !important;
}
/* Ensure the text layer uses the same font-family */
.ace_editor .ace_text-layer {
font-family: ${editorFontFamily} !important;
}
/* Adjust gutter colors */
.ace_editor .ace_gutter {
background-color: ${token.colorBgElevated} !important;
@@ -309,6 +319,11 @@ export function AsyncAceEditor(
opacity: 0.5;
}
/* Style bracket matching to blend with theme */
.ace_editor .ace_bracket {
border-color: ${token.colorPrimaryBorderHover} !important;
}
/* Adjust cursor color */
.ace_editor .ace_cursor {
color: ${token.colorPrimaryText} !important;

View File

@@ -33,20 +33,22 @@ import type { PlaceholderProps } from './types';
function DefaultPlaceholder({
width,
height,
showLoadingForImport = false,
showLoadingForImport = true,
placeholderStyle: style,
}: PlaceholderProps) {
return (
// since `width` defaults to 100%, we can display the placeholder once
// height is specified.
(height && (
if (showLoadingForImport) {
return (
<div key="async-asm-placeholder" style={{ width, height, ...style }}>
{showLoadingForImport && <Loading position="floating" />}
<Loading position="floating" size="s" />
</div>
)) ||
// `|| null` is for in case of height=0.
null
);
);
}
if (height) {
return (
<div key="async-asm-placeholder" style={{ width, height, ...style }} />
);
}
return null;
}
/**

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '../../spec';
import { render, screen, fireEvent } from '../../spec';
import CodeSyntaxHighlighter from './index';
// Simple mock that just returns the content
@@ -153,4 +153,44 @@ describe('CodeSyntaxHighlighter', () => {
expect(screen.getByText('SELECT * FROM users;')).toBeInTheDocument();
});
test('shows copy button by default', () => {
render(
<CodeSyntaxHighlighter language="sql">SELECT 1;</CodeSyntaxHighlighter>,
);
expect(screen.getByTitle('Copy to clipboard')).toBeInTheDocument();
});
test('hides copy button when showCopyButton is false', () => {
render(
<CodeSyntaxHighlighter language="sql" showCopyButton={false}>
SELECT 1;
</CodeSyntaxHighlighter>,
);
expect(screen.queryByTitle('Copy to clipboard')).not.toBeInTheDocument();
});
test('copy button does not throw when clipboard API is unavailable', () => {
const originalClipboard = navigator.clipboard;
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
});
document.execCommand = jest.fn().mockReturnValue(true);
render(
<CodeSyntaxHighlighter language="sql">SELECT 1;</CodeSyntaxHighlighter>,
);
expect(() =>
fireEvent.click(screen.getByTitle('Copy to clipboard')),
).not.toThrow();
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
configurable: true,
});
});
});

View File

@@ -16,11 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import SyntaxHighlighterBase from 'react-syntax-highlighter/dist/cjs/light';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import tomorrow from 'react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow-night';
import { isThemeDark, useTheme } from '@apache-superset/core/theme';
import { css, isThemeDark, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import copyTextToClipboard from '../../utils/copy';
import { Icons } from '../Icons';
export type SupportedLanguage = 'sql' | 'htmlbars' | 'markdown' | 'json';
@@ -31,6 +34,7 @@ export interface CodeSyntaxHighlighterProps {
showLineNumbers?: boolean;
wrapLines?: boolean;
style?: any; // Override theme style if needed
showCopyButton?: boolean;
}
// Track which languages have been registered to avoid duplicate registrations
@@ -76,11 +80,14 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
showLineNumbers = false,
wrapLines = true,
style: overrideStyle,
showCopyButton = true,
}) => {
const theme = useTheme();
const [isLanguageReady, setIsLanguageReady] = useState(
registeredLanguages.has(language),
);
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const loadLanguage = async () => {
@@ -93,6 +100,21 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
loadLanguage();
}, [language]);
useEffect(
() => () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
},
[],
);
const handleCopy = useCallback(() => {
copyTextToClipboard(() => Promise.resolve(children)).then(() => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
setCopied(true);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
});
}, [children]);
const isDark = isThemeDark(theme);
const themeStyle = overrideStyle || (isDark ? tomorrow : github);
@@ -104,32 +126,79 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
...customStyle,
};
const copyButton = showCopyButton && (
<button
type="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleCopy();
}}
title={copied ? t('Copied!') : t('Copy to clipboard')}
css={css`
position: absolute;
top: ${theme.sizeUnit}px;
right: ${theme.sizeUnit}px;
background: none;
border: none;
cursor: pointer;
padding: ${theme.sizeUnit}px;
color: ${copied ? theme.colorSuccess : theme.colorTextSecondary};
line-height: 1;
border-radius: ${theme.borderRadius}px;
&:hover {
color: ${copied ? theme.colorSuccess : theme.colorText};
background: ${theme.colorBgTextHover};
}
`}
>
{copied ? (
<Icons.CheckOutlined style={{ fontSize: theme.fontSizeSM }} />
) : (
<Icons.CopyOutlined style={{ fontSize: theme.fontSizeSM }} />
)}
</button>
);
// Show a simple pre-formatted text while language is loading
if (!isLanguageReady) {
return (
<pre
style={{
...defaultCustomStyle,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
margin: 0,
}}
<div
css={css`
position: relative;
`}
>
{children}
</pre>
{copyButton}
<pre
style={{
...defaultCustomStyle,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
margin: 0,
}}
>
{children}
</pre>
</div>
);
}
return (
<SyntaxHighlighterBase
language={language}
style={themeStyle}
customStyle={defaultCustomStyle}
showLineNumbers={showLineNumbers}
wrapLines={wrapLines}
<div
css={css`
position: relative;
`}
>
{children}
</SyntaxHighlighterBase>
{copyButton}
<SyntaxHighlighterBase
language={language}
style={themeStyle}
customStyle={defaultCustomStyle}
showLineNumbers={showLineNumbers}
wrapLines={wrapLines}
>
{children}
</SyntaxHighlighterBase>
</div>
);
};

View File

@@ -115,6 +115,7 @@ import {
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
PushpinFilled,
PushpinOutlined,
QuestionCircleOutlined,
ReloadOutlined,
@@ -270,6 +271,7 @@ const AntdIcons = {
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
PushpinFilled,
PushpinOutlined,
ReloadOutlined,
QuestionCircleOutlined,

View File

@@ -158,11 +158,13 @@ test('passes all props through to AgGridReact', () => {
/>,
);
// onGridReady and onFirstDataRendered are intercepted by the component to expose
// the grid API on the container element; the wrapped function is passed instead.
expect(AgGridReact).toHaveBeenCalledWith(
expect.objectContaining({
rowData: mockRowData,
columnDefs: mockColumnDefs,
onGridReady,
onGridReady: expect.any(Function),
onCellClicked,
pagination: true,
paginationPageSize: 10,
@@ -171,6 +173,47 @@ test('passes all props through to AgGridReact', () => {
);
});
test('onGridReady wrapper calls user callback and exposes api on container', () => {
const onGridReady = jest.fn();
render(
<ThemedAgGridReact
rowData={mockRowData}
columnDefs={mockColumnDefs}
onGridReady={onGridReady}
/>,
);
// Retrieve the wrapped handler that was passed to AgGridReact
const lastCall = (AgGridReact as jest.Mock).mock.calls.at(-1)[0];
const wrappedOnGridReady = lastCall.onGridReady as Function;
const mockApi = { setGridOption: jest.fn() };
wrappedOnGridReady({ api: mockApi });
// The user-provided callback must be forwarded
expect(onGridReady).toHaveBeenCalledWith({ api: mockApi });
});
test('onFirstDataRendered wrapper calls user callback', () => {
const onFirstDataRendered = jest.fn();
render(
<ThemedAgGridReact
rowData={mockRowData}
columnDefs={mockColumnDefs}
onFirstDataRendered={onFirstDataRendered}
/>,
);
const lastCall = (AgGridReact as jest.Mock).mock.calls.at(-1)[0];
const wrappedOnFirstDataRendered = lastCall.onFirstDataRendered as Function;
wrappedOnFirstDataRendered({ firstRow: 0 });
expect(onFirstDataRendered).toHaveBeenCalledWith({ firstRow: 0 });
});
test('applies custom theme colors from Superset theme', () => {
const customTheme = {
...supersetTheme,

View File

@@ -16,19 +16,28 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo, forwardRef } from 'react';
import { useMemo, useRef, useCallback, forwardRef } from 'react';
import { css } from '@emotion/react';
import { AgGridReact, type AgGridReactProps } from 'ag-grid-react';
import {
themeQuartz,
colorSchemeDark,
colorSchemeLight,
type GridApi,
type GridReadyEvent,
type FirstDataRenderedEvent,
} from 'ag-grid-community';
import { useTheme, useThemeMode } from '@apache-superset/core/theme';
// Note: With ag-grid v34's new theming API, CSS files are injected automatically
// Do NOT import 'ag-grid-community/styles/ag-grid.css' or theme CSS files
// Extends HTMLDivElement with ag-grid state attached to the container for downloadAsImage.
export interface AgGridContainerElement extends HTMLDivElement {
_agGridApi?: GridApi;
_agGridFirstDataRendered?: boolean;
}
export interface ThemedAgGridReactProps extends AgGridReactProps {
/**
* Optional theme parameter overrides to customize specific ag-grid theme values.
@@ -71,9 +80,13 @@ export interface ThemedAgGridReactProps extends AgGridReactProps {
export const ThemedAgGridReact = forwardRef<
AgGridReact,
ThemedAgGridReactProps
>(function ThemedAgGridReact({ themeOverrides, ...props }, ref) {
>(function ThemedAgGridReact(
{ themeOverrides, onGridReady, onFirstDataRendered, ...props },
ref,
) {
const theme = useTheme();
const isDarkMode = useThemeMode();
const containerRef = useRef<AgGridContainerElement>(null);
// Get the appropriate ag-grid theme based on dark/light mode
const agGridTheme = useMemo(() => {
@@ -140,8 +153,32 @@ export const ThemedAgGridReact = forwardRef<
return baseTheme.withParams(finalParams);
}, [theme, isDarkMode, themeOverrides]);
// Expose gridApi and first-data-rendered flag on the container for downloadAsImage.
const handleGridReady = useCallback(
(event: GridReadyEvent) => {
if (containerRef.current) {
containerRef.current._agGridFirstDataRendered = false;
containerRef.current._agGridApi = event.api;
}
onGridReady?.(event);
},
[onGridReady],
);
// Mark the container once rows are painted so downloadAsImage can gate on readiness.
const handleFirstDataRendered = useCallback(
(event: FirstDataRenderedEvent) => {
if (containerRef.current) {
containerRef.current._agGridFirstDataRendered = true;
}
onFirstDataRendered?.(event);
},
[onFirstDataRendered],
);
return (
<div
ref={containerRef}
css={css`
width: 100%;
height: 100%;
@@ -151,7 +188,13 @@ export const ThemedAgGridReact = forwardRef<
`}
data-themed-ag-grid="true"
>
<AgGridReact ref={ref} theme={agGridTheme} {...props} />
<AgGridReact
ref={ref}
theme={agGridTheme}
onGridReady={handleGridReady}
onFirstDataRendered={handleFirstDataRendered}
{...props}
/>
</div>
);
});

View File

@@ -201,6 +201,7 @@ export * from './Result';
export {
ThemedAgGridReact,
type ThemedAgGridReactProps,
type AgGridContainerElement,
setupAGGridModules,
defaultModules,
} from './ThemedAgGridReact';

View File

@@ -0,0 +1,98 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const isSafari = (): boolean => {
const { userAgent } = navigator;
return Boolean(userAgent && /^((?!chrome|android).)*safari/i.test(userAgent));
};
// Use the new Clipboard API if the browser supports it
const copyTextWithClipboardApi = async (getText: () => Promise<string>) => {
// Safari (WebKit) does not support delayed generation of clipboard.
// This means that writing to the clipboard, from the moment the user
// interacts with the app, must be instantaneous.
// However, neither writeText nor write accepts a Promise, so
// we need to create a ClipboardItem that accepts said Promise to
// delay the text generation, as needed.
// Source: https://bugs.webkit.org/show_bug.cgi?id=222262P
if (isSafari()) {
try {
const clipboardItem = new ClipboardItem({
'text/plain': getText(),
});
await navigator.clipboard.write([clipboardItem]);
} catch {
// Fallback to default clipboard API implementation
const text = await getText();
await navigator.clipboard.writeText(text);
}
} else {
// For Blink, the above method won't work, but we can use the
// default (intended) API, since the delayed generation of the
// clipboard is now supported.
// Source: https://bugs.chromium.org/p/chromium/issues/detail?id=1014310
const text = await getText();
await navigator.clipboard.writeText(text);
}
};
const copyTextToClipboard = (getText: () => Promise<string>) =>
copyTextWithClipboardApi(getText)
// If the Clipboard API is not supported, fallback to the older method.
.catch(() =>
getText().then(
text =>
new Promise<void>((resolve, reject) => {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
const span = document.createElement('span');
span.textContent = text;
span.style.position = 'fixed';
span.style.top = '0';
span.style.clip = 'rect(0, 0, 0, 0)';
span.style.whiteSpace = 'pre';
document.body.appendChild(span);
range.selectNode(span);
selection.addRange(range);
try {
if (!document.execCommand('copy')) {
reject();
}
} catch (err) {
reject();
}
document.body.removeChild(span);
if (selection.removeRange) {
selection.removeRange(range);
} else {
selection.removeAllRanges();
}
}
resolve();
}),
),
);
export default copyTextToClipboard;

View File

@@ -17,6 +17,7 @@
* under the License.
*/
export { default as convertKeysToCamelCase } from './convertKeysToCamelCase';
export { default as copyTextToClipboard } from './copy';
export { default as ensureIsArray } from './ensureIsArray';
export { default as ensureIsInt } from './ensureIsInt';
export { default as isDefined } from './isDefined';

View File

@@ -18,6 +18,9 @@
*/
// Specific modal implementations
export { ChartPropertiesModal } from './ChartPropertiesModal';
export { ConfirmDialog } from './ConfirmDialog';
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { EditDatasetModal } from './EditDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';

View File

@@ -47,7 +47,7 @@ export class ChartListPage {
}
/**
* Navigate to the chart list page.
* Navigate to the chart list page in table view.
* Forces table view via URL parameter to avoid card view default
* (ListviewsDefaultCardView feature flag may enable card view).
*/
@@ -55,6 +55,13 @@ export class ChartListPage {
await this.page.goto(`${URL.CHART_LIST}?viewMode=table`);
}
/**
* Navigate to the chart list page in card view.
*/
async gotoCardView(): Promise<void> {
await this.page.goto(`${URL.CHART_LIST}?viewMode=card`);
}
/**
* Wait for the table to load
* @param options - Optional wait options
@@ -63,6 +70,16 @@ export class ChartListPage {
await this.table.waitForVisible(options);
}
/**
* Wait for card view to finish loading.
*/
async waitForCardLoad(options?: { timeout?: number }): Promise<void> {
await this.page
.locator('[data-test="styled-card"]')
.first()
.waitFor({ state: 'visible', ...options });
}
/**
* Gets a chart row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
@@ -129,4 +146,24 @@ export class ChartListPage {
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
}
// --- Card view methods ---
/**
* Gets a chart card locator by name (card view).
*/
getChartCard(chartName: string): Locator {
return this.page
.locator('[data-test="styled-card"]')
.filter({ hasText: chartName });
}
/**
* Clicks the edit option in a chart card's dropdown menu (card view).
*/
async clickCardEditAction(chartName: string): Promise<void> {
const card = this.getChartCard(chartName);
await card.locator('[aria-label="more"]').click();
await this.page.locator('[data-test="chart-list-edit-option"]').click();
}
}

View File

@@ -17,21 +17,20 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { ChartListPage } from '../../pages/ChartListPage';
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { ChartListPage } from '../../../pages/ChartListPage';
import { ChartPropertiesModal } from '../../../components/modals/ChartPropertiesModal';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { Toast } from '../../../components/core/Toast';
import { apiGetChart, ENDPOINTS } from '../../../helpers/api/chart';
ChartPropertiesModal,
DeleteConfirmationModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import { apiGetChart, ENDPOINTS } from '../../helpers/api/chart';
import { createTestChart } from './chart-test-helpers';
import { waitForGet, waitForPut } from '../../../helpers/api/intercepts';
import { waitForGet, waitForPut } from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
} from '../../helpers/api/assertions';
/**
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
@@ -261,6 +260,60 @@ test('should bulk delete multiple charts', async ({
}
});
test('should edit chart name from card view', async ({ page, testAssets }) => {
// Create throwaway chart for editing
const { id: chartId, name: chartName } = await createTestChart(
page,
testAssets,
test.info(),
{ prefix: 'test_card_edit' },
);
// Navigate to card view (not table view)
const cardListPage = new ChartListPage(page);
await cardListPage.gotoCardView();
await cardListPage.waitForCardLoad();
// Verify chart card is visible
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
// Open card dropdown and click edit
await cardListPage.clickCardEditAction(chartName);
// Wait for properties modal to be ready
const propertiesModal = new ChartPropertiesModal(page);
await propertiesModal.waitForReady();
// Edit the chart name
const newName = `card_renamed_${Date.now()}_${test.info().parallelIndex}`;
await propertiesModal.fillName(newName);
// Set up response intercept for save
const saveResponsePromise = waitForPut(page, `${ENDPOINTS.CHART}${chartId}`);
// Click Save button
await propertiesModal.clickSave();
// Wait for save to complete and verify success
expectStatusOneOf(await saveResponsePromise, [200, 201]);
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify the renamed card appears in card view and old name is gone
await expect(cardListPage.getChartCard(newName)).toBeVisible();
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
// Backend verification: API returns updated name
const response = await apiGetChart(page, chartId);
const chart = (await response.json()).result;
expect(chart.slice_name).toBe(newName);
});
test('should bulk export multiple charts', async ({
page,
chartListPage,

View File

@@ -18,9 +18,9 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostChart } from '../../../helpers/api/chart';
import { getDatasetByName } from '../../../helpers/api/dataset';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
interface TestChartResult {
id: number;

View File

@@ -17,28 +17,27 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { DashboardListPage } from '../../pages/DashboardListPage';
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { DashboardListPage } from '../../../pages/DashboardListPage';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { Toast } from '../../../components/core/Toast';
DeleteConfirmationModal,
ImportDatasetModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import {
apiGetDashboard,
apiDeleteDashboard,
apiExportDashboards,
getDashboardByName,
ENDPOINTS,
} from '../../../helpers/api/dashboard';
} from '../../helpers/api/dashboard';
import { createTestDashboard } from './dashboard-test-helpers';
import { waitForGet, waitForPost } from '../../../helpers/api/intercepts';
import { waitForGet, waitForPost } from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with dashboardListPage navigation (beforeEach equivalent).

View File

@@ -18,8 +18,8 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostDashboard } from '../../../helpers/api/dashboard';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostDashboard } from '../../helpers/api/dashboard';
interface TestDashboardResult {
id: number;

View File

@@ -18,9 +18,9 @@
*/
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../../../pages/DashboardPage';
import { Toast } from '../../../components/core';
import { TIMEOUT } from '../../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import { Toast } from '../../components/core';
import { TIMEOUT } from '../../utils/constants';
/**
* Dashboard Export E2E tests.

View File

@@ -18,16 +18,16 @@
*/
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../../pages/AuthPage';
import { DashboardPage } from '../../../pages/DashboardPage';
import { apiPostTheme, apiDeleteTheme } from '../../../helpers/api/theme';
import { AuthPage } from '../../pages/AuthPage';
import { DashboardPage } from '../../pages/DashboardPage';
import { apiPostTheme, apiDeleteTheme } from '../../helpers/api/theme';
import {
apiPostDashboard,
apiPutDashboard,
apiDeleteDashboard,
} from '../../../helpers/api/dashboard';
import { apiGet } from '../../../helpers/api/requests';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/dashboard';
import { apiGet } from '../../helpers/api/requests';
import { TIMEOUT } from '../../utils/constants';
/**
* Dashboard Theme E2E tests.

View File

@@ -17,17 +17,17 @@
* under the License.
*/
import { test, expect } from '../../../helpers/fixtures/testAssets';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { testWithAssets as test, expect } from '../../helpers/fixtures';
import type { TestAssets } from '../../helpers/fixtures';
import type { Page, TestInfo } from '@playwright/test';
import { ExplorePage } from '../../../pages/ExplorePage';
import { CreateDatasetPage } from '../../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ChartCreationPage } from '../../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../../helpers/api/dataset';
import { waitForPost } from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import { apiPostDatabase } from '../../../helpers/api/database';
import { ExplorePage } from '../../pages/ExplorePage';
import { CreateDatasetPage } from '../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ChartCreationPage } from '../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../helpers/api/dataset';
import { waitForPost } from '../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../helpers/api/assertions';
import { apiPostDatabase } from '../../helpers/api/database';
interface GsheetsSetupResult {
sheetName: string;

View File

@@ -17,37 +17,36 @@
* under the License.
*/
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { testWithAssets, expect } from '../../helpers/fixtures';
import path from 'path';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
import { EditDatasetModal } from '../../../components/modals/EditDatasetModal';
import { Toast } from '../../../components/core/Toast';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ExplorePage } from '../../pages/ExplorePage';
import {
ConfirmDialog,
DeleteConfirmationModal,
DuplicateDatasetModal,
EditDatasetModal,
ImportDatasetModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import {
apiDeleteDataset,
apiGetDataset,
apiPostVirtualDataset,
getDatasetByName,
ENDPOINTS,
} from '../../../helpers/api/dataset';
} from '../../helpers/api/dataset';
import { createTestDataset } from './dataset-test-helpers';
import {
waitForGet,
waitForPost,
waitForPut,
} from '../../../helpers/api/intercepts';
} from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with datasetListPage navigation (beforeEach equivalent).
@@ -458,11 +457,12 @@ test.describe('import dataset', () => {
testAssets,
}) => {
// Dataset name from fixture (test_netflix_1768502050965)
// Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable
// Note: Fixture contains a Google Sheets dataset backed by shillelagh[gsheetsapi],
// which is a base dependency — import failure fails the test hard (no skip).
const importedDatasetName = 'test_netflix_1768502050965';
const fixturePath = path.resolve(
__dirname,
'../../../fixtures/dataset_export.zip',
'../../fixtures/dataset_export.zip',
);
// Cleanup: Delete any existing dataset with the same name from previous runs
@@ -518,25 +518,12 @@ test.describe('import dataset', () => {
importResponse = await importResponsePromise;
}
// Check final import response for gsheets connector errors
// Fail hard if dataset import fails.
// The fixture contains a gsheets dataset; shillelagh[gsheetsapi] is a base
// dependency (pyproject.toml), so the engine is always available in CI.
if (!importResponse.ok()) {
const errorBody = await importResponse.json().catch(() => ({}));
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine') ||
errorText.includes('Could not load database driver')
) {
await test.info().attach('skip-reason', {
body: `Import failed due to missing gsheets connector: ${errorText}`,
contentType: 'text/plain',
});
test.skip();
return;
}
// Re-throw other errors
throw new Error(`Import failed: ${errorText}`);
throw new Error(`Import failed: ${JSON.stringify(errorBody)}`);
}
// Modal should close on success

View File

@@ -18,8 +18,8 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { createTestVirtualDataset } from '../../../helpers/api/dataset';
import type { TestAssets } from '../../helpers/fixtures';
import { createTestVirtualDataset } from '../../helpers/api/dataset';
interface TestDatasetResult {
id: number;

View File

@@ -21,6 +21,7 @@
import d3 from 'd3';
import { extent as d3Extent } from 'd3-array';
import {
ValueFormatter,
getNumberFormatter,
getSequentialSchemeRegistry,
CategoricalColorNamespace,
@@ -60,7 +61,8 @@ interface CountryMapProps {
height: number;
country: string;
linearColorScheme: string;
numberFormat: string;
numberFormat?: string; // left for backward compatibility
formatter: ValueFormatter;
colorScheme: string;
sliceId: number;
}
@@ -74,13 +76,12 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
height,
country,
linearColorScheme,
numberFormat,
formatter,
colorScheme,
sliceId,
} = props;
const container = element;
const format = getNumberFormatter(numberFormat);
const rawExtents = d3Extent(data, v => v.metric);
const extents: [number, number] =
rawExtents[0] != null && rawExtents[1] != null
@@ -182,7 +183,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.style('top', `${position[1] + 30}px`)
.style('left', `${position[0]}px`)
.html(
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? format(result[0].metric) : ''}</div>`,
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
);
};

View File

@@ -69,6 +69,7 @@ const config: ControlPanelConfig = {
},
},
],
['currency_format'],
['linear_color_scheme'],
],
},

View File

@@ -16,26 +16,48 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps } from '@superset-ui/core';
import { ChartProps, getValueFormatter } from '@superset-ui/core';
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, queriesData } = chartProps;
const { width, height, formData, queriesData, datasource } = chartProps;
const {
linearColorScheme,
numberFormat,
currencyFormat,
selectCountry,
colorScheme,
sliceId,
metric,
} = formData;
const {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
} = datasource;
const { data, detected_currency: detectedCurrency } = queriesData[0];
const formatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
currencyFormat,
undefined, // key - not needed for single-metric charts
data,
currencyCodeColumn,
detectedCurrency,
);
return {
width,
height,
data: queriesData[0].data,
country: selectCountry ? String(selectCountry).toLowerCase() : null,
linearColorScheme,
numberFormat,
numberFormat, // left for backward compatibility
colorScheme,
sliceId,
formatter,
};
}

View File

@@ -93,6 +93,7 @@ describe('CountryMap (legacy d3)', () => {
linearColorScheme="bnbColors"
colorScheme=""
numberFormat=".2f"
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -115,6 +116,7 @@ describe('CountryMap (legacy d3)', () => {
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -144,6 +146,7 @@ describe('CountryMap (legacy d3)', () => {
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
/>,
);

View File

@@ -1,55 +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.
-->
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.20.0](https://github.com/apache/superset/compare/v2021.41.0...v0.20.0) (2024-09-09)
### Bug Fixes
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
### Features
- apply standardized form data to tier 2 charts ([#20530](https://github.com/apache/superset/issues/20530)) ([de524bc](https://github.com/apache/superset/commit/de524bc59f011fd361dcdb7d35c2cb51f7eba442))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
# [0.19.0](https://github.com/apache/superset/compare/v2021.41.0...v0.19.0) (2024-09-07)
### Bug Fixes
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
### Features
- apply standardized form data to tier 2 charts ([#20530](https://github.com/apache/superset/issues/20530)) ([de524bc](https://github.com/apache/superset/commit/de524bc59f011fd361dcdb7d35c2cb51f7eba442))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
# [0.18.0](https://github.com/apache-superset/superset-ui/compare/v0.17.87...v0.18.0) (2021-08-30)
**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-map-box
## [0.17.61](https://github.com/apache-superset/superset-ui/compare/v0.17.60...v0.17.61) (2021-07-02)
**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-map-box

View File

@@ -1,52 +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.
-->
## @superset-ui/legacy-plugin-chart-map-box
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-map-box.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/legacy-plugin-chart-map-box)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Flegacy-plugin-chart-map-box?style=flat)](https://libraries.io/npm/@superset-ui%2Flegacy-plugin-chart-map-box)
This plugin provides MapBox for Superset.
### Usage
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to
lookup this chart throughout the app.
```js
import MapBoxChartPlugin from '@superset-ui/legacy-plugin-chart-map-box';
new MapBoxChartPlugin().configure({ key: 'map-box' }).register();
```
Then use it via `SuperChart`. See
[storybook](https://apache-superset.github.io/superset-ui-plugins/?selectedKind=plugin-chart-map-box)
for more details.
```js
<SuperChart
chartType="map-box"
width={600}
height={600}
formData={...}
queriesData={[{
data: {...},
}]}
/>
```

View File

@@ -1,243 +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.
*/
/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
/* eslint-disable react/forbid-prop-types, react/require-default-props */
import { Component } from 'react';
import MapGL from 'react-map-gl';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
import './MapBox.css';
const NOOP = () => {};
export const DEFAULT_MAX_ZOOM = 16;
export const DEFAULT_POINT_RADIUS = 60;
interface Viewport {
longitude: number;
latitude: number;
zoom: number;
isDragging?: boolean;
}
interface Clusterer {
getClusters(bbox: number[], zoom: number): GeoJSONLocation[];
}
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface MapBoxProps {
width?: number;
height?: number;
aggregatorName?: string;
clusterer: Clusterer; // Required - used for getClusters()
globalOpacity?: number;
hasCustomMetric?: boolean;
mapStyle?: string;
mapboxApiKey: string;
onViewportChange?: (viewport: Viewport) => void;
pointRadius?: number;
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
viewportLongitude?: number;
viewportLatitude?: number;
viewportZoom?: number;
}
interface MapBoxState {
viewport: Viewport;
}
const defaultProps: Partial<MapBoxProps> = {
width: 400,
height: 400,
globalOpacity: 1,
onViewportChange: NOOP,
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit: 'Pixels',
};
class MapBox extends Component<MapBoxProps, MapBoxState> {
static defaultProps = defaultProps;
constructor(props: MapBoxProps) {
super(props);
const fitBounds = this.computeFitBoundsViewport();
this.state = {
viewport: this.mergeViewportWithProps(fitBounds),
};
this.handleViewportChange = this.handleViewportChange.bind(this);
}
handleViewportChange(viewport: Viewport) {
this.setState({ viewport });
const { onViewportChange } = this.props;
onViewportChange!(viewport);
}
mergeViewportWithProps(
fitBounds: Viewport,
viewport: Viewport = fitBounds,
props: MapBoxProps = this.props,
useFitBoundsForUnset = true,
): Viewport {
const { viewportLongitude, viewportLatitude, viewportZoom } = props;
return {
...viewport,
longitude:
viewportLongitude ??
(useFitBoundsForUnset ? fitBounds.longitude : viewport.longitude),
latitude:
viewportLatitude ??
(useFitBoundsForUnset ? fitBounds.latitude : viewport.latitude),
zoom:
viewportZoom ?? (useFitBoundsForUnset ? fitBounds.zoom : viewport.zoom),
};
}
computeFitBoundsViewport(): Viewport {
const { width = 400, height = 400, bounds } = this.props;
if (bounds && bounds[0] && bounds[1]) {
const mercator = new WebMercatorViewport({ width, height }).fitBounds(
bounds,
);
return {
latitude: mercator.latitude,
longitude: mercator.longitude,
zoom: mercator.zoom,
};
}
return { latitude: 0, longitude: 0, zoom: 1 };
}
componentDidUpdate(prevProps: MapBoxProps) {
const { viewport } = this.state;
const fitBoundsInputsChanged =
prevProps.width !== this.props.width ||
prevProps.height !== this.props.height ||
prevProps.bounds !== this.props.bounds;
const viewportPropsChanged =
prevProps.viewportLongitude !== this.props.viewportLongitude ||
prevProps.viewportLatitude !== this.props.viewportLatitude ||
prevProps.viewportZoom !== this.props.viewportZoom;
if (!fitBoundsInputsChanged && !viewportPropsChanged) {
return;
}
const fitBounds = this.computeFitBoundsViewport();
const nextViewport = this.mergeViewportWithProps(
fitBounds,
viewport,
this.props,
fitBoundsInputsChanged || viewportPropsChanged,
);
const viewportChanged =
nextViewport.longitude !== viewport.longitude ||
nextViewport.latitude !== viewport.latitude ||
nextViewport.zoom !== viewport.zoom;
if (viewportChanged) {
this.setState({ viewport: nextViewport });
}
}
render() {
const {
width,
height,
aggregatorName,
clusterer,
globalOpacity,
mapStyle,
mapboxApiKey,
pointRadius,
pointRadiusUnit,
renderWhileDragging,
rgb,
hasCustomMetric,
bounds,
} = this.props;
const { viewport } = this.state;
const isDragging =
viewport.isDragging === undefined ? false : viewport.isDragging;
// Compute the clusters based on the original bounds and current zoom level. Note when zoom/pan
// to an area outside of the original bounds, no additional queries are made to the backend to
// retrieve additional data.
// add this variable to widen the visible area
const offsetHorizontal = ((width ?? 400) * 0.5) / 100;
const offsetVertical = ((height ?? 400) * 0.5) / 100;
// Guard against empty datasets where bounds may be undefined
const bbox =
bounds && bounds[0] && bounds[1]
? [
bounds[0][0] - offsetHorizontal,
bounds[0][1] - offsetVertical,
bounds[1][0] + offsetHorizontal,
bounds[1][1] + offsetVertical,
]
: [-180, -90, 180, 90]; // Default to world bounds
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
return (
<MapGL
{...viewport}
mapStyle={mapStyle}
width={width}
height={height}
mapboxApiAccessToken={mapboxApiKey}
onViewportChange={this.handleViewportChange}
preserveDrawingBuffer
>
<ScatterPlotGlowOverlay
{...viewport}
isDragging={isDragging}
locations={clusters}
dotRadius={pointRadius}
pointRadiusUnit={pointRadiusUnit}
rgb={rgb}
globalOpacity={globalOpacity}
compositeOperation="screen"
renderWhileDragging={renderWhileDragging}
aggregation={hasCustomMetric ? aggregatorName : undefined}
lngLatAccessor={(location: GeoJSONLocation) => {
const { coordinates } = location.geometry;
return [coordinates[0], coordinates[1]];
}}
/>
</MapGL>
);
}
}
export default MapBox;

View File

@@ -1,425 +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.
*/
/* eslint-disable react/require-default-props */
import { PureComponent } from 'react';
import { CanvasOverlay } from 'react-map-gl';
import { kmToPixels, MILES_PER_KM } from './utils/geo';
import roundDecimal from './utils/roundDecimal';
import luminanceFromRGB from './utils/luminanceFromRGB';
import 'mapbox-gl/dist/mapbox-gl.css';
// Shared radius bounds keep cluster and point sizing in sync.
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface RedrawParams {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}
interface DrawTextOptions {
fontHeight?: number;
label?: string | number;
radius?: number;
rgb?: (string | number)[];
shadow?: boolean;
}
interface ScatterPlotGlowOverlayProps {
aggregation?: string;
compositeOperation?: string;
dotRadius?: number;
globalOpacity?: number;
lngLatAccessor?: (location: GeoJSONLocation) => [number, number];
locations: GeoJSONLocation[];
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
zoom?: number;
isDragging?: boolean;
}
const defaultProps: Partial<ScatterPlotGlowOverlayProps> = {
// Same as browser default.
compositeOperation: 'source-over',
dotRadius: 4,
lngLatAccessor: (location: GeoJSONLocation) => [
location.geometry.coordinates[0],
location.geometry.coordinates[1],
],
renderWhileDragging: true,
};
const computeClusterLabel = (
properties: Record<string, number | string | boolean | null | undefined>,
aggregation: string | undefined,
): number | string => {
const count = properties.point_count as number;
if (!aggregation) {
return count;
}
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
return properties[aggregation] as number;
}
const { sum } = properties as { sum: number };
const mean = sum / count;
if (aggregation === 'mean') {
return Math.round(100 * mean) / 100;
}
const { squaredSum } = properties as { squaredSum: number };
const variance = squaredSum / count - (sum / count) ** 2;
if (aggregation === 'var') {
return Math.round(100 * variance) / 100;
}
if (aggregation === 'stdev') {
return Math.round(100 * Math.sqrt(variance)) / 100;
}
// fallback to point_count, this really shouldn't happen
return count;
};
class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps> {
static defaultProps = defaultProps;
constructor(props: ScatterPlotGlowOverlayProps) {
super(props);
this.redraw = this.redraw.bind(this);
}
drawText(
ctx: CanvasRenderingContext2D,
pixel: [number, number],
options: DrawTextOptions = {},
) {
const IS_DARK_THRESHOLD = 110;
const {
fontHeight = 0,
label = '',
radius = 0,
rgb = [0, 0, 0],
shadow = false,
} = options;
const maxWidth = radius * 1.8;
const luminance = luminanceFromRGB(
rgb[1] as number,
rgb[2] as number,
rgb[3] as number,
);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
ctx.font = `${fontHeight}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (shadow) {
ctx.shadowBlur = 15;
ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
}
const textWidth = ctx.measureText(String(label)).width;
if (textWidth > maxWidth) {
const scale = fontHeight / textWidth;
ctx.font = `${scale * maxWidth}px sans-serif`;
}
const { compositeOperation } = this.props;
ctx.fillText(String(label), pixel[0], pixel[1]);
ctx.globalCompositeOperation = (compositeOperation ??
'source-over') as GlobalCompositeOperation;
ctx.shadowBlur = 0;
ctx.shadowColor = '';
}
// Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js
redraw({ width, height, ctx, isDragging, project }: RedrawParams) {
const {
aggregation,
compositeOperation,
dotRadius,
globalOpacity,
lngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging,
rgb,
zoom,
} = this.props;
const radius = dotRadius ?? 4;
const clusterLabelMap: (number | string)[] = [];
locations.forEach((location, i) => {
if (location.properties.cluster) {
clusterLabelMap[i] = computeClusterLabel(
location.properties,
aggregation,
);
}
});
const finiteClusterLabels = clusterLabelMap
.map(value => Number(value))
.filter(value => Number.isFinite(value));
const safeMaxAbsLabel =
finiteClusterLabels.length > 0
? Math.max(
Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
1,
)
: 1;
// Calculate min/max radius values for Pixels mode scaling
let minRadiusValue = Infinity;
let maxRadiusValue = -Infinity;
if (pointRadiusUnit === 'Pixels') {
locations.forEach(location => {
// Accept both null and undefined as "no value" and coerce potential numeric strings
if (
!location.properties.cluster &&
location.properties.radius != null
) {
const radiusValueRaw = location.properties.radius;
const radiusValue =
typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
? null
: Number(radiusValueRaw);
if (radiusValue != null && Number.isFinite(radiusValue)) {
minRadiusValue = Math.min(minRadiusValue, radiusValue);
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
}
}
});
}
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation = (compositeOperation ??
'source-over') as GlobalCompositeOperation;
if ((renderWhileDragging || !isDragging) && locations) {
locations.forEach(function _forEach(
this: ScatterPlotGlowOverlay,
location: GeoJSONLocation,
i: number,
) {
const pixel = project(lngLatAccessor!(location)) as [number, number];
const pixelRounded: [number, number] = [
roundDecimal(pixel[0], 1),
roundDecimal(pixel[1], 1),
];
if (
pixelRounded[0] + radius >= 0 &&
pixelRounded[0] - radius < width &&
pixelRounded[1] + radius >= 0 &&
pixelRounded[1] - radius < height
) {
ctx.beginPath();
if (location.properties.cluster) {
const clusterLabel = clusterLabelMap[i];
// Validate clusterLabel is a finite number before using it for radius calculation
const numericLabel = Number(clusterLabel);
const safeNumericLabel = Number.isFinite(numericLabel)
? numericLabel
: 0;
const minClusterRadius =
pointRadiusUnit === 'Pixels'
? radius * MAX_POINT_RADIUS_RATIO
: radius * MIN_CLUSTER_RADIUS_RATIO;
const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
const scaledRadius = roundDecimal(
minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
1,
);
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
const [x, y] = pixelRounded;
const gradient = ctx.createRadialGradient(
x,
y,
scaledRadius,
x,
y,
0,
);
gradient.addColorStop(
1,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * (globalOpacity ?? 1)})`,
);
gradient.addColorStop(
0,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
);
ctx.arc(
pixelRounded[0],
pixelRounded[1],
scaledRadius,
0,
Math.PI * 2,
);
ctx.fillStyle = gradient;
ctx.fill();
if (Number.isFinite(safeNumericLabel)) {
let label: string | number = clusterLabel;
const absLabel = Math.abs(safeNumericLabel);
const sign = safeNumericLabel < 0 ? '-' : '';
if (absLabel >= 10000) {
label = `${sign}${Math.round(absLabel / 1000)}k`;
} else if (absLabel >= 1000) {
label = `${sign}${Math.round(absLabel / 100) / 10}k`;
}
this.drawText(ctx, pixelRounded, {
fontHeight,
label,
radius: scaledRadius,
rgb,
shadow: true,
});
}
} else {
const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
const rawRadius = location.properties.radius;
const numericRadiusProperty =
rawRadius != null &&
!(typeof rawRadius === 'string' && rawRadius.trim() === '')
? Number(rawRadius)
: null;
const radiusProperty =
numericRadiusProperty != null &&
Number.isFinite(numericRadiusProperty)
? numericRadiusProperty
: null;
const pointMetric = location.properties.metric ?? null;
let pointRadius: number = radiusProperty ?? defaultRadius;
let pointLabel: string | number | undefined;
if (radiusProperty != null) {
const pointLatitude = lngLatAccessor!(location)[1];
if (pointRadiusUnit === 'Kilometers') {
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
pointRadius = kmToPixels(pointRadius, pointLatitude, zoom ?? 0);
} else if (pointRadiusUnit === 'Miles') {
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
pointRadius = kmToPixels(
pointRadius * MILES_PER_KM,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Pixels') {
// Scale pixel values to a reasonable range (radius/6 to radius/3)
// This ensures points are visible and proportional to their values
const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
if (
Number.isFinite(minRadiusValue) &&
Number.isFinite(maxRadiusValue) &&
maxRadiusValue > minRadiusValue
) {
// Normalize the value to 0-1 range, then scale to pixel range
const numericPointRadius = Number(pointRadius);
if (!Number.isFinite(numericPointRadius)) {
// fallback to minimum visible size when the value is not a finite number
pointRadius = MIN_POINT_RADIUS;
} else {
const normalizedValueRaw =
(numericPointRadius - minRadiusValue) /
(maxRadiusValue - minRadiusValue);
const normalizedValue = Math.max(
0,
Math.min(1, normalizedValueRaw),
);
pointRadius =
MIN_POINT_RADIUS +
normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
}
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else if (
Number.isFinite(minRadiusValue) &&
minRadiusValue === maxRadiusValue
) {
// All values are the same, use a fixed medium size
pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else {
// Use raw pixel values if they're already in a reasonable range
pointRadius = Math.max(
MIN_POINT_RADIUS,
Math.min(pointRadius, MAX_POINT_RADIUS),
);
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
}
}
}
if (pointMetric !== null) {
const numericMetric = parseFloat(String(pointMetric));
pointLabel = Number.isFinite(numericMetric)
? roundDecimal(numericMetric, 2)
: String(pointMetric);
}
// Fall back to default points if pointRadius wasn't a numerical column
if (!pointRadius) {
pointRadius = defaultRadius;
}
ctx.arc(
pixelRounded[0],
pixelRounded[1],
roundDecimal(pointRadius, 1),
0,
Math.PI * 2,
);
ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
ctx.fill();
if (pointLabel !== undefined) {
this.drawText(ctx, pixelRounded, {
fontHeight: roundDecimal(pointRadius, 1),
label: pointLabel,
radius: pointRadius,
rgb,
shadow: false,
});
}
}
}
}, this);
}
}
render() {
return <CanvasOverlay redraw={this.redraw} />;
}
}
export default ScatterPlotGlowOverlay;

View File

@@ -1,107 +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.
*/
/* eslint-disable no-magic-numbers */
import { SuperChart } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import MapBoxChartPlugin from '@superset-ui/legacy-plugin-chart-map-box';
import { withResizableChartDemo } from '@storybook-shared';
import { generateData } from './data';
new MapBoxChartPlugin().configure({ key: 'map-box' }).register();
export default {
title: 'Legacy Chart Plugins/legacy-plugin-chart-map-box',
decorators: [withResizableChartDemo],
args: {
clusteringRadius: 60,
globalOpacity: 1,
pointRadius: 'Auto',
renderWhileDragging: true,
},
argTypes: {
clusteringRadius: {
control: { type: 'range', min: 0, max: 200, step: 10 },
description: 'Radius in pixels for clustering points',
},
globalOpacity: {
control: { type: 'range', min: 0, max: 1, step: 0.1 },
description: 'Opacity of map markers',
},
pointRadius: {
control: 'select',
options: ['Auto', 1, 2, 5, 10, 20, 50],
description: 'Size of point markers',
},
renderWhileDragging: {
control: 'boolean',
description: 'Render markers while dragging the map',
},
},
parameters: {
docs: {
description: {
component:
'Note: This chart requires a Mapbox API key to display. Without a valid key, the map background will not render.',
},
},
},
};
export const MapBoxViz = ({
clusteringRadius,
globalOpacity,
pointRadius,
renderWhileDragging,
width,
height,
}: {
clusteringRadius: number;
globalOpacity: number;
pointRadius: string | number;
renderWhileDragging: boolean;
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="map-box"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
all_columns_x: 'LON',
all_columns_y: 'LAT',
clustering_radius: String(clusteringRadius),
global_opacity: globalOpacity,
mapbox_color: 'rgb(244, 176, 42)',
mapbox_label: [],
mapbox_style: 'mapbox://styles/mapbox/light-v9',
pandas_aggfunc: 'sum',
point_radius: pointRadius,
point_radius_unit: 'Pixels',
render_while_dragging: renderWhileDragging,
viewport_latitude: 37.78711146014447,
viewport_longitude: -122.37633433151713,
viewport_zoom: 10.026425338292782,
}}
/>
);
};

View File

@@ -1,162 +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 Supercluster, {
type Options as SuperclusterOptions,
} from 'supercluster';
import { ChartProps } from '@superset-ui/core';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapBox';
const NOOP = () => {};
const MIN_LONGITUDE = -180;
const MAX_LONGITUDE = 180;
const MIN_LATITUDE = -90;
const MAX_LATITUDE = 90;
const MIN_ZOOM = 0;
function toFiniteNumber(
value: string | number | null | undefined,
): number | undefined {
if (value === null || value === undefined) return undefined;
const normalizedValue = typeof value === 'string' ? value.trim() : value;
if (normalizedValue === '') return undefined;
const num = Number(normalizedValue);
return Number.isFinite(num) ? num : undefined;
}
function clampNumber(
value: number | undefined,
min: number,
max: number,
): number | undefined {
if (value === undefined) return undefined;
return Math.min(max, Math.max(min, value));
}
interface ClusterProperties {
metric: number;
sum: number;
squaredSum: number;
min: number;
max: number;
}
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, hooks, queriesData } = chartProps;
const { onError = NOOP, setControlValue = NOOP } = hooks;
const { bounds, geoJSON, hasCustomMetric, mapboxApiKey } =
queriesData[0].data;
const {
clusteringRadius,
globalOpacity,
mapboxColor,
mapboxStyle,
pandasAggfunc,
pointRadiusUnit,
renderWhileDragging,
viewportLongitude,
viewportLatitude,
viewportZoom,
} = formData;
// Validate mapbox color
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(mapboxColor);
if (rgb === null) {
onError("Color field must be of form 'rgb(%d, %d, %d)'");
return {};
}
const opts: SuperclusterOptions<ClusterProperties, ClusterProperties> = {
maxZoom: DEFAULT_MAX_ZOOM,
radius: clusteringRadius,
};
if (hasCustomMetric) {
opts.initial = () => ({
metric: 0,
sum: 0,
squaredSum: 0,
min: Infinity,
max: -Infinity,
});
opts.map = (prop: ClusterProperties) => ({
metric: prop.metric,
sum: prop.metric,
squaredSum: prop.metric ** 2,
min: prop.metric,
max: prop.metric,
});
opts.reduce = (accu: ClusterProperties, prop: ClusterProperties) => {
// Temporarily disable param-reassignment linting to work with supercluster's api
/* eslint-disable no-param-reassign */
accu.sum += prop.sum;
accu.squaredSum += prop.squaredSum;
accu.min = Math.min(accu.min, prop.min);
accu.max = Math.max(accu.max, prop.max);
/* eslint-enable no-param-reassign */
};
}
const clusterer = new Supercluster(opts);
clusterer.load(geoJSON.features);
return {
width,
height,
aggregatorName: pandasAggfunc,
bounds,
clusterer,
hasCustomMetric,
mapboxApiKey,
mapStyle: mapboxStyle,
onViewportChange({
latitude,
longitude,
zoom,
}: {
latitude: number;
longitude: number;
zoom: number;
}) {
setControlValue('viewport_longitude', longitude);
setControlValue('viewport_latitude', latitude);
setControlValue('viewport_zoom', zoom);
},
// Always use DEFAULT_POINT_RADIUS as the base radius for cluster sizing
// Individual point radii come from geoJSON properties.radius
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit,
renderWhileDragging,
rgb,
viewportLongitude: clampNumber(
toFiniteNumber(viewportLongitude),
MIN_LONGITUDE,
MAX_LONGITUDE,
),
viewportLatitude: clampNumber(
toFiniteNumber(viewportLatitude),
MIN_LATITUDE,
MAX_LATITUDE,
),
viewportZoom: clampNumber(
toFiniteNumber(viewportZoom),
MIN_ZOOM,
DEFAULT_MAX_ZOOM,
),
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
};
}

View File

@@ -1,381 +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 { type ReactNode } from 'react';
import { render } from '@testing-library/react';
import MapBox from '../src/MapBox';
// Capture the most recent viewport props passed to MapGL
let lastMapGLProps: Record<string, unknown> = {};
const mockFitBounds = jest.fn();
jest.mock('react-map-gl', () => {
const MockMapGL = (props: Record<string, unknown>) => {
lastMapGLProps = props;
return <div data-test="map-gl">{props.children as ReactNode}</div>;
};
return { __esModule: true, default: MockMapGL };
});
jest.mock('@math.gl/web-mercator', () => ({
WebMercatorViewport: jest
.fn()
.mockImplementation(
({ width, height }: { width: number; height: number }) => ({
fitBounds: (bounds: [[number, number], [number, number]]) =>
mockFitBounds(bounds, width, height),
}),
),
}));
jest.mock('../src/ScatterPlotGlowOverlay', () => {
const MockOverlay = (props: Record<string, unknown>) => (
<div data-test="scatter-overlay" data-opacity={props.globalOpacity} />
);
return { __esModule: true, default: MockOverlay };
});
const defaultProps = {
width: 800,
height: 600,
clusterer: {
getClusters: jest.fn().mockReturnValue([]),
},
globalOpacity: 1,
mapboxApiKey: 'test-key',
mapStyle: 'mapbox://styles/mapbox/light-v9',
pointRadius: 60,
pointRadiusUnit: 'Pixels',
renderWhileDragging: true,
rgb: ['', 255, 0, 0] as (string | number)[],
hasCustomMetric: false,
bounds: [
[-74.0, 40.7],
[-73.9, 40.8],
] as [[number, number], [number, number]],
onViewportChange: jest.fn(),
};
beforeEach(() => {
lastMapGLProps = {};
jest.clearAllMocks();
mockFitBounds.mockImplementation(
(
bounds: [[number, number], [number, number]],
width: number,
height: number,
) => ({
latitude: Number(((bounds[0][1] + bounds[1][1]) / 2).toFixed(2)),
longitude: Number(((bounds[0][0] + bounds[1][0]) / 2).toFixed(2)),
zoom: Number((10 + width / 1000 + height / 10000).toFixed(2)),
}),
);
});
test('initializes viewport from bounds', () => {
render(<MapBox {...defaultProps} />);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('updates viewport when viewport props change', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('does not loop when viewport state matches new props', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
// Re-render with same props that match the initial viewport state
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
// Viewport should still be the fitBounds-computed values since props didn't change
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10);
});
test('passes globalOpacity to ScatterPlotGlowOverlay', () => {
const { getByTestId } = render(
<MapBox {...defaultProps} globalOpacity={0.5} />,
);
const overlay = getByTestId('scatter-overlay');
expect(overlay.dataset.opacity).toBe('0.5');
});
test('initializes viewport from props when provided', () => {
render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('handles undefined bounds gracefully', () => {
render(<MapBox {...defaultProps} bounds={undefined} />);
expect(lastMapGLProps.longitude).toBe(0);
expect(lastMapGLProps.latitude).toBe(0);
expect(lastMapGLProps.zoom).toBe(1);
});
test('applies partial viewport props on update', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(<MapBox {...defaultProps} viewportLongitude={-122.4} />);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('restores fitBounds when viewport props are cleared', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear all viewport props (simulates user clearing the controls)
rerender(<MapBox {...defaultProps} />);
// Should revert to fitBounds values
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('restores only cleared viewport props, keeps the rest', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear only longitude, keep lat/zoom
rerender(
<MapBox {...defaultProps} viewportLatitude={37.8} viewportZoom={5} />,
);
// Longitude reverts to fitBounds, lat/zoom stay
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('applies changed viewport props even when another is cleared simultaneously', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear longitude, change latitude simultaneously
rerender(
<MapBox {...defaultProps} viewportLatitude={40.0} viewportZoom={5} />,
);
// Longitude reverts to fitBounds, latitude should be the NEW value
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.0);
expect(lastMapGLProps.zoom).toBe(5);
});
test('falls back to default viewport when cleared with undefined bounds', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
bounds={undefined}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear viewport props — no bounds to fitBounds to
rerender(<MapBox {...defaultProps} bounds={undefined} />);
// Should fall back to {0, 0, 1}
expect(lastMapGLProps.longitude).toBe(0);
expect(lastMapGLProps.latitude).toBe(0);
expect(lastMapGLProps.zoom).toBe(1);
});
test('recomputes fitBounds when bounds change and no explicit viewport is set', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(
<MapBox
{...defaultProps}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.5);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('recomputes fitBounds when chart size changes and no explicit viewport is set', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(<MapBox {...defaultProps} width={1200} height={900} />);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(11.29);
});
test('recomputes only implicit viewport fields when bounds change', () => {
const { rerender } = render(
<MapBox {...defaultProps} viewportLongitude={-122.4} />,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('recomputes only implicit viewport fields when chart size changes', () => {
const { rerender } = render(
<MapBox {...defaultProps} viewportLatitude={37.8} />,
);
rerender(
<MapBox
{...defaultProps}
viewportLatitude={37.8}
width={1200}
height={900}
/>,
);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(11.29);
});
test('recomputes implicit position when zoom stays explicit across bounds changes', () => {
const { rerender } = render(<MapBox {...defaultProps} viewportZoom={5} />);
rerender(
<MapBox
{...defaultProps}
viewportZoom={5}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.5);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(5);
});
test('does not recompute fitBounds on bounds change when an explicit viewport is set', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,25 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Path Resolution: Override baseUrl to maintain correct path mappings from parent config
// (e.g., "@apache-superset/core" -> "./packages/superset-core/src")
"baseUrl": "../..",
// Directory Overrides: Parent config paths are relative to frontend root,
// but packages need paths relative to their own directory
"outDir": "lib",
"rootDir": "src",
"declarationDir": "lib"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]
}

View File

@@ -1,101 +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.
*/
declare module '*.png' {
const value: string;
export default value;
}
declare module '*.jpg' {
const value: string;
export default value;
}
declare module 'supercluster' {
interface Options<P = Record<string, unknown>, C = Record<string, unknown>> {
minZoom?: number;
maxZoom?: number;
minPoints?: number;
radius?: number;
extent?: number;
nodeSize?: number;
log?: boolean;
initial?: () => C;
map?: (props: P) => C;
reduce?: (accumulated: C, props: C) => void;
}
interface GeoJSONFeature {
type: string;
geometry: {
type: string;
coordinates: [number, number];
};
properties: Record<string, unknown>;
}
class Supercluster<P = Record<string, unknown>, C = Record<string, unknown>> {
constructor(options?: Options<P, C>);
load(points: GeoJSONFeature[]): Supercluster<P, C>;
getClusters(bbox: number[], zoom: number): GeoJSONFeature[];
getTile(z: number, x: number, y: number): GeoJSONFeature[] | null;
getChildren(clusterId: number): GeoJSONFeature[];
getLeaves(
clusterId: number,
limit?: number,
offset?: number,
): GeoJSONFeature[];
getClusterExpansionZoom(clusterId: number): number;
}
export default Supercluster;
export { Options, GeoJSONFeature };
}
declare module 'react-map-gl' {
import { Component, ReactNode } from 'react';
interface MapGLProps {
width?: number;
height?: number;
latitude?: number;
longitude?: number;
zoom?: number;
mapStyle?: string;
mapboxApiAccessToken?: string;
onViewportChange?: Function;
preserveDrawingBuffer?: boolean;
children?: ReactNode;
[key: string]: unknown;
}
export default class MapGL extends Component<MapGLProps> {}
interface CanvasOverlayProps {
redraw: (params: {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}) => void;
}
export class CanvasOverlay extends Component<CanvasOverlayProps> {}
}

View File

@@ -1,89 +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.
-->
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.20.4](https://github.com/apache/superset/compare/v0.20.3...v0.20.4) (2024-12-10)
**Note:** Version bump only for package @superset-ui/legacy-preset-chart-deckgl
# [0.20.0](https://github.com/apache/superset/compare/v2021.41.0...v0.20.0) (2024-09-09)
### Bug Fixes
- **Dashboard:** Color inconsistency on refreshes and conflicts ([#27439](https://github.com/apache/superset/issues/27439)) ([313ee59](https://github.com/apache/superset/commit/313ee596f5435894f857d72be7269d5070c8c964))
- deck.gl Geojson path not visible ([#24428](https://github.com/apache/superset/issues/24428)) ([6bb930e](https://github.com/apache/superset/commit/6bb930ef4ed26ea381e7f8e889851aa7867ba0eb))
- deck.gl GeoJsonLayer Autozoom & fill/stroke options ([#19778](https://github.com/apache/superset/issues/19778)) ([d65b77e](https://github.com/apache/superset/commit/d65b77ec7dac4c2368fcaa1fe6e98db102966198))
- **deck.gl Multiple Layer Chart:** Add Contour and Heatmap Layer as options ([#25923](https://github.com/apache/superset/issues/25923)) ([64ba579](https://github.com/apache/superset/commit/64ba5797df92d0f8067ccd2b30ba6ff58e0bd791))
- deck.gl Scatterplot min/max radius ([#24363](https://github.com/apache/superset/issues/24363)) ([c728cdf](https://github.com/apache/superset/commit/c728cdf501ec292beb14a0982265052bf2274bec))
- **deck.gl:** multiple layers map size is shrunk ([#18939](https://github.com/apache/superset/issues/18939)) ([2cb3635](https://github.com/apache/superset/commit/2cb3635256ee8e91f0bac2f3091684673c04ff2b))
- **deck.gl:** update view state on property changes ([#17720](https://github.com/apache/superset/issues/17720)) ([#17826](https://github.com/apache/superset/issues/17826)) ([97d918b](https://github.com/apache/superset/commit/97d918b6927f572dca3b33c61b89c8b3ebdc4376))
- DeckGL legend layout ([#30140](https://github.com/apache/superset/issues/30140)) ([af066a4](https://github.com/apache/superset/commit/af066a46306f2f476aa2944b14df3de1faf1e96d))
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Fix chart standalone URL for report/thumbnail generation ([#20673](https://github.com/apache/superset/issues/20673)) ([84d4302](https://github.com/apache/superset/commit/84d4302628d18aa19c13cc5322e68abbc690ea4d))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
- weight tooltip issue ([#19397](https://github.com/apache/superset/issues/19397)) ([f6d550b](https://github.com/apache/superset/commit/f6d550b7fc3643350483850064e65dbd3d026dc4))
### Features
- Add Deck.gl Contour Layer ([#24154](https://github.com/apache/superset/issues/24154)) ([512fb9a](https://github.com/apache/superset/commit/512fb9a0bdd428b94b0c121158b8b15b7631e0fb))
- Add deck.gl Heatmap Visualization ([#23551](https://github.com/apache/superset/issues/23551)) ([fc8c537](https://github.com/apache/superset/commit/fc8c537118ce6c7b3a4624f88a31e2e7fb287327))
- Add line width unit control in deckgl Polygon and Path ([#24755](https://github.com/apache/superset/issues/24755)) ([d26ea98](https://github.com/apache/superset/commit/d26ea980acc7d2a20757efc360d810afe83d5c65))
- apply standardized form data to deckgl ([#20579](https://github.com/apache/superset/issues/20579)) ([290b89c](https://github.com/apache/superset/commit/290b89c7b4ae702c55f611bfac9cedb245ea8bd8))
- **deck.gl:** add color range for deck.gl 3D ([#19520](https://github.com/apache/superset/issues/19520)) ([c0a00fd](https://github.com/apache/superset/commit/c0a00fd302ec66fbe0ca766cf73978c99ba00d82))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
- **explore:** Frontend implementation of dataset creation from infobox ([#19855](https://github.com/apache/superset/issues/19855)) ([ba0c37d](https://github.com/apache/superset/commit/ba0c37d3df85b1af39404af1d578daeb0ff2d278))
- improve color consistency (save all labels) ([#19038](https://github.com/apache/superset/issues/19038)) ([dc57508](https://github.com/apache/superset/commit/dc575080d7e43d40b1734bb8f44fdc291cb95b11))
- **legacy-preset-chart-deckgl:** Add ,.1f and ,.2f value formats to deckgl charts ([#18945](https://github.com/apache/superset/issues/18945)) ([c56dc8e](https://github.com/apache/superset/commit/c56dc8eace6a71b45240d1bb6768d75661052a2e))
- make data tables support html ([#24368](https://github.com/apache/superset/issues/24368)) ([d2b0b8e](https://github.com/apache/superset/commit/d2b0b8eac52ad8b68639c6581a1ed174a593f564))
- **viz picker:** Remove some tags, refactor Recommended section ([#27708](https://github.com/apache/superset/issues/27708)) ([c314999](https://github.com/apache/superset/commit/c3149994ac0d4392e0462421b62cd0c034142082))
# [0.19.0](https://github.com/apache/superset/compare/v2021.41.0...v0.19.0) (2024-09-07)
### Bug Fixes
- **Dashboard:** Color inconsistency on refreshes and conflicts ([#27439](https://github.com/apache/superset/issues/27439)) ([313ee59](https://github.com/apache/superset/commit/313ee596f5435894f857d72be7269d5070c8c964))
- deck.gl Geojson path not visible ([#24428](https://github.com/apache/superset/issues/24428)) ([6bb930e](https://github.com/apache/superset/commit/6bb930ef4ed26ea381e7f8e889851aa7867ba0eb))
- deck.gl GeoJsonLayer Autozoom & fill/stroke options ([#19778](https://github.com/apache/superset/issues/19778)) ([d65b77e](https://github.com/apache/superset/commit/d65b77ec7dac4c2368fcaa1fe6e98db102966198))
- **deck.gl Multiple Layer Chart:** Add Contour and Heatmap Layer as options ([#25923](https://github.com/apache/superset/issues/25923)) ([64ba579](https://github.com/apache/superset/commit/64ba5797df92d0f8067ccd2b30ba6ff58e0bd791))
- deck.gl Scatterplot min/max radius ([#24363](https://github.com/apache/superset/issues/24363)) ([c728cdf](https://github.com/apache/superset/commit/c728cdf501ec292beb14a0982265052bf2274bec))
- **deck.gl:** multiple layers map size is shrunk ([#18939](https://github.com/apache/superset/issues/18939)) ([2cb3635](https://github.com/apache/superset/commit/2cb3635256ee8e91f0bac2f3091684673c04ff2b))
- **deck.gl:** update view state on property changes ([#17720](https://github.com/apache/superset/issues/17720)) ([#17826](https://github.com/apache/superset/issues/17826)) ([97d918b](https://github.com/apache/superset/commit/97d918b6927f572dca3b33c61b89c8b3ebdc4376))
- DeckGL legend layout ([#30140](https://github.com/apache/superset/issues/30140)) ([af066a4](https://github.com/apache/superset/commit/af066a46306f2f476aa2944b14df3de1faf1e96d))
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Fix chart standalone URL for report/thumbnail generation ([#20673](https://github.com/apache/superset/issues/20673)) ([84d4302](https://github.com/apache/superset/commit/84d4302628d18aa19c13cc5322e68abbc690ea4d))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
- weight tooltip issue ([#19397](https://github.com/apache/superset/issues/19397)) ([f6d550b](https://github.com/apache/superset/commit/f6d550b7fc3643350483850064e65dbd3d026dc4))
### Features
- Add Deck.gl Contour Layer ([#24154](https://github.com/apache/superset/issues/24154)) ([512fb9a](https://github.com/apache/superset/commit/512fb9a0bdd428b94b0c121158b8b15b7631e0fb))
- Add deck.gl Heatmap Visualization ([#23551](https://github.com/apache/superset/issues/23551)) ([fc8c537](https://github.com/apache/superset/commit/fc8c537118ce6c7b3a4624f88a31e2e7fb287327))
- Add line width unit control in deckgl Polygon and Path ([#24755](https://github.com/apache/superset/issues/24755)) ([d26ea98](https://github.com/apache/superset/commit/d26ea980acc7d2a20757efc360d810afe83d5c65))
- apply standardized form data to deckgl ([#20579](https://github.com/apache/superset/issues/20579)) ([290b89c](https://github.com/apache/superset/commit/290b89c7b4ae702c55f611bfac9cedb245ea8bd8))
- **deck.gl:** add color range for deck.gl 3D ([#19520](https://github.com/apache/superset/issues/19520)) ([c0a00fd](https://github.com/apache/superset/commit/c0a00fd302ec66fbe0ca766cf73978c99ba00d82))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
- **explore:** Frontend implementation of dataset creation from infobox ([#19855](https://github.com/apache/superset/issues/19855)) ([ba0c37d](https://github.com/apache/superset/commit/ba0c37d3df85b1af39404af1d578daeb0ff2d278))
- improve color consistency (save all labels) ([#19038](https://github.com/apache/superset/issues/19038)) ([dc57508](https://github.com/apache/superset/commit/dc575080d7e43d40b1734bb8f44fdc291cb95b11))
- **legacy-preset-chart-deckgl:** Add ,.1f and ,.2f value formats to deckgl charts ([#18945](https://github.com/apache/superset/issues/18945)) ([c56dc8e](https://github.com/apache/superset/commit/c56dc8eace6a71b45240d1bb6768d75661052a2e))
- make data tables support html ([#24368](https://github.com/apache/superset/issues/24368)) ([d2b0b8e](https://github.com/apache/superset/commit/d2b0b8eac52ad8b68639c6581a1ed174a593f564))
- **viz picker:** Remove some tags, refactor Recommended section ([#27708](https://github.com/apache/superset/issues/27708)) ([c314999](https://github.com/apache/superset/commit/c3149994ac0d4392e0462421b62cd0c034142082))

View File

@@ -1,57 +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.
-->
## @superset-ui/legacy-preset-chart-deckgl
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-preset-chart-deckgl.svg?style=flat)](https://img.shields.io/npm/v/@superset-ui/legacy-preset-chart-deckgl.svg?style=flat-square)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Flegacy-preset-chart-deckgl?style=flat)](https://libraries.io/npm/@superset-ui%2Flegacy-preset-chart-deckgl)
This plugin provides `deck.gl` for Superset.
### Usage
Import the preset and register. This will register all the chart plugins under `deck.gl`.
```js
import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl';
new DeckGLChartPreset().register();
```
or register charts one by one. Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
```js
import { ArcChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
new ArcChartPlugin().configure({ key: 'deck_arc' }).register();
```
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-plugins-deckgl) for more details.
```js
<SuperChart
chartType="deck_arc"
width={600}
height={600}
formData={...}
queriesData={[{
data: {...},
}]}
/>
```

View File

@@ -1,71 +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.
*/
declare module 'deck.gl' {
import { Layer, LayerProps } from '@deck.gl/core';
interface HeatmapLayerProps<T extends object = any> extends LayerProps<T> {
id?: string;
data?: T[];
getPosition?: (d: T) => number[] | null | undefined;
getWeight?: (d: T) => number | null | undefined;
radiusPixels?: number;
colorRange?: number[][];
threshold?: number;
intensity?: number;
aggregation?: string;
}
interface ContourLayerProps<T extends object = any> extends LayerProps<T> {
id?: string;
data?: T[];
getPosition?: (d: T) => number[] | null | undefined;
getWeight?: (d: T) => number | null | undefined;
contours: {
color?: ColorType | undefined;
lowerThreshold?: any | undefined;
upperThreshold?: any | undefined;
strokeWidth?: any | undefined;
zIndex?: any | undefined;
};
cellSize: number;
colorRange?: number[][];
intensity?: number;
aggregation?: string;
}
export class HeatmapLayer<T extends object = any> extends Layer<
T,
HeatmapLayerProps<T>
> {
constructor(props: HeatmapLayerProps<T>);
}
export class ContourLayer<T extends object = any> extends Layer<
T,
ContourLayerProps<T>
> {
constructor(props: ContourLayerProps<T>);
}
}
declare module '*.png' {
const value: any;
export default value;
}

View File

@@ -32,7 +32,7 @@
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.3.3",
"prop-types": "^15.8.1",

View File

@@ -28,15 +28,15 @@
"@types/react-table": "^7.7.20",
"classnames": "^2.5.1",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"memoize-one": "^5.2.1",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -31,14 +31,14 @@
"dependencies": {
"@types/geojson": "^7946.0.16",
"geojson": "^0.5.0",
"lodash": "^4.17.23"
"lodash": "^4.18.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@reduxjs/toolkit": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@types/react-redux": "*",
"geostyler": "^18.3.1",
"geostyler-data": "^1.0.0",

View File

@@ -28,7 +28,7 @@
"@types/react-redux": "^7.1.34",
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"zod": "^4.3.6"
},
"peerDependencies": {

View File

@@ -659,7 +659,10 @@ export default function transformProps(
for (const s of series) {
if (s.id) {
const columnsArr = labelMap[s.id];
(s as any).stack = columnsArr[idxSelectedDimension];
const dimensionValue = columnsArr?.[idxSelectedDimension];
if (dimensionValue !== undefined) {
(s as any).stack = dimensionValue;
}
}
}
}
@@ -682,9 +685,24 @@ export default function transformProps(
// For horizontal bar charts, set max/min from calculated data bounds
if (shouldCalculateDataBounds) {
// Set max to actual data max to avoid gaps and ensure labels are visible
if (dataMax !== undefined && yAxisMax === undefined) {
yAxisMax = dataMax;
// For stacked charts, clamp against the per-row stacked total to avoid
// clipping bars. Also keep dataMax so that mixed-sign stacks (where
// positive and negative values cancel in the algebraic row sum) cannot
// produce an axis max smaller than the largest individual positive segment.
const stackedTotalMax = Math.max(
...sortedTotalValues.filter(
(v): v is number => typeof v === 'number' && !Number.isNaN(v),
),
);
const effectiveDataMax = stack
? Math.max(dataMax ?? Number.NEGATIVE_INFINITY, stackedTotalMax)
: dataMax;
if (
effectiveDataMax !== undefined &&
Number.isFinite(effectiveDataMax) &&
yAxisMax === undefined
) {
yAxisMax = effectiveDataMax;
}
// Set min to actual data min for diverging bars
if (dataMin !== undefined && yAxisMin === undefined && dataMin < 0) {

View File

@@ -273,18 +273,28 @@ function Echart(
);
const notMerge = !isDashboardRefreshing;
if (!notMerge) {
chartRef.current?.dispatchAction({ type: 'hideTip' });
}
chartRef.current?.dispatchAction({ type: 'hideTip' });
chartRef.current?.setOption(themedEchartOptions, {
notMerge,
replaceMerge: notMerge ? undefined : ['series'],
lazyUpdate: isDashboardRefreshing,
// lazyUpdate defers render, causing tooltip crashes on stale shapes (#39247)
lazyUpdate: false,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
// Clear tooltip on refresh start to avoid stale content (#39247)
useEffect(() => {
if (didMount && isDashboardRefreshing && chartRef.current) {
chartRef.current.dispatchAction({ type: 'hideTip' });
chartRef.current.dispatchAction({
type: 'updateAxisPointer',
currTrigger: 'leave',
});
}
}, [didMount, isDashboardRefreshing]);
useEffect(() => () => chartRef.current?.dispose(), []);
// highlighting

View File

@@ -29,7 +29,8 @@ import { Refs } from '../types';
export function getDefaultTooltip(refs: Refs) {
return {
appendToBody: true,
appendToBody:
typeof document !== 'undefined' ? !document.fullscreenElement : true,
borderColor: 'transparent',
// CSS hack applied on this class to resolve https://github.com/apache/superset/issues/30058
className: 'echarts-tooltip',

View File

@@ -16,67 +16,66 @@
* specific language governing permissions and limitations
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
import { isPostProcessingRank, QueryFormData } from '@superset-ui/core';
import buildQuery from '../../src/Heatmap/buildQuery';
describe('Heatmap buildQuery - Rank Operation for Normalized Field', () => {
const baseFormData = {
datasource: '5__table',
granularity_sqla: 'ds',
metric: 'count',
x_axis: 'category',
groupby: ['region'],
viz_type: 'heatmap',
} as QueryFormData;
const baseFormData = {
datasource: '5__table',
granularity_sqla: 'ds',
metric: 'count',
x_axis: 'category',
groupby: ['region'],
viz_type: 'heatmap',
} as QueryFormData;
test('should ALWAYS include rank operation when normalized=true', () => {
const formData = {
...baseFormData,
normalized: true,
};
const getQuery = (formData: QueryFormData) => buildQuery(formData).queries[0];
const getRankOperation = (formData: QueryFormData) =>
getQuery(formData).post_processing?.find(isPostProcessingRank);
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
const rankOperation = query.post_processing?.find(
op => op?.operation === 'rank',
);
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
test('adds X axis orderby when sorting alphabetically ascending', () => {
const query = getQuery({
...baseFormData,
sort_x_axis: 'alpha_asc',
});
test('should ALWAYS include rank operation when normalized=false', () => {
const formData = {
...baseFormData,
normalized: false,
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
const rankOperation = query.post_processing?.find(
op => op?.operation === 'rank',
);
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});
test('should ALWAYS include rank operation when normalized is undefined', () => {
const formData = {
...baseFormData,
// normalized not set
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
const rankOperation = query.post_processing?.find(
op => op?.operation === 'rank',
);
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});
expect(query.orderby).toEqual([['category', true]]);
});
test('adds Y axis orderby when sorting alphabetically descending', () => {
const query = getQuery({
...baseFormData,
sort_y_axis: 'alpha_desc',
});
expect(query.orderby).toEqual([['region', false]]);
});
test('should ALWAYS include rank operation when normalized=true', () => {
const rankOperation = getRankOperation({
...baseFormData,
normalized: true,
});
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});
test('should ALWAYS include rank operation when normalized=false', () => {
const rankOperation = getRankOperation({
...baseFormData,
normalized: false,
});
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});
test('should ALWAYS include rank operation when normalized is undefined', () => {
const rankOperation = getRankOperation({
...baseFormData,
// normalized not set
});
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});

View File

@@ -24,6 +24,7 @@ import {
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import { StackControlsValue } from '../../../src/constants';
import type {
GridComponentOption,
LegendComponentOption,
@@ -727,6 +728,97 @@ describe('Bar Chart X-axis Time Formatting', () => {
});
});
describe('Horizontal stacked bar chart axis bounds', () => {
// Dataset where each series max = 4 but stacked total max = 8
const stackedData: ChartDataResponseResult[] = [
createTestQueryData(
[
{ team: 'Team A', High: 2, Low: 2, Medium: 4 },
{ team: 'Team B', High: null, Low: null, Medium: 3 },
{ team: 'Team C', High: null, Low: null, Medium: 1 },
],
{
colnames: ['team', 'High', 'Low', 'Medium'],
coltypes: [
GenericDataType.String,
GenericDataType.Numeric,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
},
),
];
const horizontalStackedFormData: EchartsTimeseriesFormData = {
...(baseFormData as EchartsTimeseriesFormData),
x_axis: 'team',
metric: ['High', 'Low', 'Medium'],
groupby: [],
orientation: OrientationType.Horizontal,
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
truncateYAxis: true,
};
test('xAxis.max uses stacked total, not individual series max', () => {
// Individual series max = 4 (Medium), stacked total for Team A = 8
// Without the fix, xAxis.max would be 4, clipping bars and duplicating labels
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: horizontalStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// xAxis.max must be >= stacked total (8), not capped at individual series max (4)
expect(xAxis.max).toBeGreaterThanOrEqual(8);
});
test('xAxis.max is not set to individual series max when stacking', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: horizontalStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// 4 is the individual series max — the axis should not be clipped there
expect(xAxis.max).not.toBe(4);
});
test('non-stacked horizontal bar chart still uses individual series max', () => {
const nonStackedFormData: EchartsTimeseriesFormData = {
...horizontalStackedFormData,
stack: null,
};
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: nonStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// Without stacking, xAxis.max should be based on individual series values
expect(xAxis.max).toBe(4);
});
});
describe('Legend layout regressions', () => {
const getBottomLegendLayout = (
chartWidth: number,

View File

@@ -37,7 +37,7 @@
"@superset-ui/core": "*",
"ace-builds": "^1.4.14",
"handlebars": "^4.7.8",
"lodash": "^4.17.11",
"lodash": "^4.18.1",
"dayjs": "^1.11.19",
"react": "^17.0.2",
"react-ace": "^10.1.0",

View File

@@ -30,10 +30,12 @@ import { debounceFunc } from '../../consts';
interface StyleCustomControlProps {
value: string;
htmlSanitization: boolean;
}
const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
const theme = useTheme();
const htmlSanitization = props.htmlSanitization ?? true;
const defaultValue = props?.value
? undefined
@@ -48,10 +50,16 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
<ControlHeader>
<div>
{props.label}
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={t('You need to configure HTML sanitization to use CSS')}
/>
{htmlSanitization && (
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={t(
'CSS styles may be removed by server-side HTML sanitization. ' +
'If styles are not applying, ask your Superset administrator ' +
'to adjust the HTML sanitization configuration.',
)}
/>
)}
</div>
</ControlHeader>
<CodeEditor
@@ -79,8 +87,9 @@ export const styleControlSetItem: ControlSetItem = {
valueKey: null,
validators: [],
mapStateToProps: ({ controls }) => ({
mapStateToProps: ({ controls, common }) => ({
value: controls?.handlebars_template?.value,
htmlSanitization: common?.conf?.HTML_SANITIZATION ?? true,
}),
},
};

View File

@@ -27,12 +27,11 @@
"access": "public"
},
"peerDependencies": {
"react-icons": "5.4.0",
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"lodash": "^4.17.11",
"lodash": "^4.18.1",
"prop-types": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2"

View File

@@ -22,9 +22,11 @@ import { safeHtmlSpan } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { supersetTheme } from '@apache-superset/core/theme';
import PropTypes from 'prop-types';
import { FaSort } from 'react-icons/fa';
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
import {
CaretUpOutlined,
CaretDownOutlined,
ColumnHeightOutlined,
} from '@ant-design/icons';
import {
ColorFormatters,
getTextColorForBackground,
@@ -855,7 +857,7 @@ export class TableRenderer extends Component<
if (activeSortColumn !== key) {
return (
<FaSort
<ColumnHeightOutlined
onClick={() =>
this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
}
@@ -863,7 +865,8 @@ export class TableRenderer extends Component<
);
}
const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
const SortIcon =
sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined;
return (
<SortIcon
onClick={() =>
@@ -873,7 +876,9 @@ export class TableRenderer extends Component<
);
};
const headerCellFormattedValue =
dateFormatters?.[attrName]?.(convertToNumberIfNumeric(colKey[attrIdx])) ?? colKey[attrIdx];
dateFormatters?.[attrName]?.(
convertToNumberIfNumeric(colKey[attrIdx]),
) ?? colKey[attrIdx];
const { backgroundColor, color } = getCellColor(
[attrName],
headerCellFormattedValue,

View File

@@ -1,7 +1,7 @@
{
"name": "@superset-ui/legacy-plugin-chart-map-box",
"version": "0.20.3",
"description": "Superset Legacy Chart - MapBox",
"name": "@superset-ui/plugin-chart-point-cluster-map",
"version": "1.0.0",
"description": "Superset Chart Plugin - Point Cluster Map",
"keywords": [
"superset"
],
@@ -12,7 +12,7 @@
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/plugins/legacy-plugin-chart-map-box"
"directory": "superset-frontend/plugins/plugin-chart-point-cluster-map"
},
"license": "Apache-2.0",
"author": "Superset",
@@ -27,16 +27,17 @@
],
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"prop-types": "^15.8.1",
"react-map-gl": "^6.1.19",
"mapbox-gl": "^3.0.0",
"maplibre-gl": "^5.0.0",
"react-map-gl": "^8.0.0",
"supercluster": "^8.0.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"mapbox-gl": "*",
"react": "^17.0.2"
"react": "^17.0.2 || ^19.0.0",
"react-dom": "^17.0.2 || ^19.0.0"
},
"publishConfig": {
"access": "public"

View File

@@ -16,6 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
.mapbox .slice_container div {
.maplibre .slice_container div {
padding-top: 0px;
}

View File

@@ -0,0 +1,216 @@
/**
* 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 { memo, useCallback, useEffect, useState } from 'react';
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
import { Map as MapboxMap } from 'react-map-gl/mapbox';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import { useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import ScatterPlotOverlay from './components/ScatterPlotOverlay';
import { getMapboxApiKey } from './utils/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import './MapLibre.css';
const DEFAULT_MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
export const DEFAULT_MAX_ZOOM = 16;
export const DEFAULT_POINT_RADIUS = 60;
interface Viewport {
longitude: number;
latitude: number;
zoom: number;
}
interface Clusterer {
getClusters(bbox: number[], zoom: number): GeoJSONLocation[];
}
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface MapLibreProps {
width?: number;
height?: number;
aggregatorName?: string;
clusterer: Clusterer; // Required - used for getClusters()
globalOpacity?: number;
hasCustomMetric?: boolean;
mapProvider?: string;
mapStyle?: string;
onViewportChange?: (viewport: Viewport) => void;
pointRadius?: number;
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
viewportLongitude?: number;
viewportLatitude?: number;
viewportZoom?: number;
}
function MapLibre({
width = 400,
height = 400,
aggregatorName,
clusterer,
globalOpacity = 1,
hasCustomMetric,
mapProvider,
mapStyle,
onViewportChange,
pointRadius = DEFAULT_POINT_RADIUS,
pointRadiusUnit = 'Pixels',
renderWhileDragging = true,
rgb,
bounds,
viewportLongitude,
viewportLatitude,
viewportZoom,
}: MapLibreProps) {
const computeFitBounds = useCallback((): Viewport => {
if (bounds && bounds[0] && bounds[1]) {
const mercator = new WebMercatorViewport({ width, height }).fitBounds(
bounds,
);
return {
latitude: mercator.latitude,
longitude: mercator.longitude,
zoom: mercator.zoom,
};
}
return { latitude: 0, longitude: 0, zoom: 1 };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const mergeViewportWithProps = useCallback(
(fitBounds: Viewport, base: Viewport = fitBounds): Viewport => ({
...base,
longitude: viewportLongitude ?? fitBounds.longitude,
latitude: viewportLatitude ?? fitBounds.latitude,
zoom: viewportZoom ?? fitBounds.zoom,
}),
[viewportLongitude, viewportLatitude, viewportZoom],
);
const [viewport, setViewport] = useState<Viewport>(() =>
mergeViewportWithProps(computeFitBounds()),
);
useEffect(() => {
const fitBounds = computeFitBounds();
const next = mergeViewportWithProps(fitBounds, viewport);
if (
next.longitude !== viewport.longitude ||
next.latitude !== viewport.latitude ||
next.zoom !== viewport.zoom
) {
setViewport(next);
}
// Only re-run when the viewport-override props change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewportLongitude, viewportLatitude, viewportZoom]);
const handleMove = useCallback(
(evt: {
viewState: { longitude: number; latitude: number; zoom: number };
}) => {
const { longitude, latitude, zoom } = evt.viewState;
const newViewport = { longitude, latitude, zoom };
setViewport(newViewport);
onViewportChange?.(newViewport);
},
[onViewportChange],
);
// add this variable to widen the visible area
const offsetHorizontal = (width * 0.5) / 100;
const offsetVertical = (height * 0.5) / 100;
const bbox =
bounds && bounds[0] && bounds[1]
? [
bounds[0][0] - offsetHorizontal,
bounds[0][1] - offsetVertical,
bounds[1][0] + offsetHorizontal,
bounds[1][1] + offsetVertical,
]
: [-180, -90, 180, 90];
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
const theme = useTheme();
const resolvedMapStyle = mapStyle || DEFAULT_MAP_STYLE;
const mapboxApiKey = mapProvider === 'mapbox' ? getMapboxApiKey() : '';
if (mapProvider === 'mapbox' && !mapboxApiKey) {
return (
<div
style={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
textAlign: 'center',
color: theme.colorTextSecondary,
}}
>
{t('Mapbox requires a MAPBOX_API_KEY to be configured on the server.')}
</div>
);
}
const MapComponent = mapProvider === 'mapbox' ? MapboxMap : MapLibreMap;
const mapboxProps =
mapProvider === 'mapbox' ? { mapboxAccessToken: mapboxApiKey } : {};
return (
<MapComponent
{...viewport}
{...mapboxProps}
style={{ width, height }}
mapStyle={resolvedMapStyle}
onMove={handleMove}
>
<ScatterPlotOverlay
locations={clusters}
dotRadius={pointRadius}
pointRadiusUnit={pointRadiusUnit}
rgb={rgb}
globalOpacity={globalOpacity}
compositeOperation="screen"
renderWhileDragging={renderWhileDragging}
aggregation={hasCustomMetric ? aggregatorName : undefined}
zoom={viewport.zoom}
lngLatAccessor={(location: GeoJSONLocation) => {
const { coordinates } = location.geometry;
return [coordinates[0], coordinates[1]];
}}
/>
</MapComponent>
);
}
export default memo(MapLibre);

View File

@@ -0,0 +1,90 @@
/**
* 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 {
buildQueryContext,
ensureIsArray,
QueryFormColumn,
QueryObject,
QueryObjectFilterClause,
SqlaFormData,
} from '@superset-ui/core';
export interface MapLibreFormData extends SqlaFormData {
all_columns_x?: string;
all_columns_y?: string;
map_label?: string[];
point_radius?: string;
clustering_radius?: string;
pandas_aggfunc?: string;
global_opacity?: number;
maplibre_style?: string;
mapbox_style?: string;
map_color?: string;
render_while_dragging?: boolean;
point_radius_unit?: string;
}
export default function buildQuery(formData: MapLibreFormData) {
const { all_columns_x, all_columns_y, map_label, point_radius } = formData;
if (!all_columns_x || !all_columns_y) {
throw new Error('Longitude and latitude columns are required');
}
return buildQueryContext(formData, (baseQueryObject: QueryObject) => {
const columns: QueryFormColumn[] = [
...ensureIsArray(baseQueryObject.columns || []),
all_columns_x,
all_columns_y,
];
// Add label column if specified and not 'count'
const hasCustomMetric =
map_label && map_label.length > 0 && map_label[0] !== 'count';
if (hasCustomMetric) {
columns.push(map_label[0]);
}
// Add point radius column if not "Auto"
if (point_radius && point_radius !== 'Auto') {
columns.push(point_radius);
}
// Add null filters for lon/lat
const filters: QueryObjectFilterClause[] = ensureIsArray(
baseQueryObject.filters || [],
);
filters.push(
{ col: all_columns_x, op: 'IS NOT NULL' },
{ col: all_columns_y, op: 'IS NOT NULL' },
);
// Deduplicate columns
const uniqueColumns = [...new Set(columns)];
return [
{
...baseQueryObject,
columns: uniqueColumns,
filters,
is_timeseries: false,
},
];
});
}

View File

@@ -0,0 +1,121 @@
/**
* 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 { useCallback, useEffect, useRef } from 'react';
import { useMap as useMapLibre } from 'react-map-gl/maplibre';
import { useMap as useMapbox } from 'react-map-gl/mapbox';
export interface RedrawParams {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}
interface CanvasOverlayProps {
redraw: (params: RedrawParams) => void;
}
export default function CanvasOverlay({ redraw }: CanvasOverlayProps) {
const mapLibreContext = useMapLibre();
const mapboxContext = useMapbox();
const mapRef = (mapLibreContext.current ?? mapboxContext.current) as any;
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDraggingRef = useRef(false);
const project = useCallback(
(lngLat: [number, number]): [number, number] => {
if (!mapRef) return [0, 0];
const map = mapRef.getMap();
const point = map.project(lngLat);
return [point.x, point.y];
},
[mapRef],
);
const performRedraw = useCallback(() => {
const canvas = canvasRef.current;
const map = mapRef?.getMap();
if (!canvas || !map) return;
const container = map.getContainer();
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redraw({
width,
height,
ctx,
isDragging: isDraggingRef.current,
project,
});
}, [mapRef, redraw, project]);
useEffect(() => {
const map = mapRef?.getMap();
if (!map) return undefined;
const onMove = () => performRedraw();
const onDragStart = () => {
isDraggingRef.current = true;
};
const onDragEnd = () => {
isDraggingRef.current = false;
performRedraw();
};
const onResize = () => performRedraw();
map.on('move', onMove);
map.on('dragstart', onDragStart);
map.on('dragend', onDragEnd);
map.on('resize', onResize);
performRedraw();
return () => {
map.off('move', onMove);
map.off('dragstart', onDragStart);
map.off('dragend', onDragEnd);
map.off('resize', onResize);
};
}, [mapRef, performRedraw]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
}}
/>
);
}

View File

@@ -0,0 +1,400 @@
/**
* 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 { memo, useCallback } from 'react';
import CanvasOverlay, { type RedrawParams } from './CanvasOverlay';
import { kmToPixels, MILES_PER_KM } from '../utils/geo';
import roundDecimal from '../utils/roundDecimal';
import luminanceFromRGB from '../utils/luminanceFromRGB';
// Shared radius bounds keep cluster and point sizing in sync.
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface DrawTextOptions {
fontHeight?: number;
label?: string | number;
radius?: number;
rgb?: (string | number)[];
shadow?: boolean;
}
interface ScatterPlotOverlayProps {
aggregation?: string;
compositeOperation?: string;
dotRadius?: number;
globalOpacity?: number;
lngLatAccessor?: (location: GeoJSONLocation) => [number, number];
locations: GeoJSONLocation[];
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
zoom?: number;
}
const IS_DARK_THRESHOLD = 110;
const defaultLngLatAccessor = (location: GeoJSONLocation): [number, number] => [
location.geometry.coordinates[0],
location.geometry.coordinates[1],
];
const computeClusterLabel = (
properties: Record<string, number | string | boolean | null | undefined>,
aggregation: string | undefined,
): number | string => {
const count = properties.point_count as number;
if (!aggregation) {
return count;
}
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
return properties[aggregation] as number;
}
const { sum } = properties as { sum: number };
const mean = sum / count;
if (aggregation === 'mean') {
return Math.round(100 * mean) / 100;
}
const { squaredSum } = properties as { squaredSum: number };
const variance = squaredSum / count - (sum / count) ** 2;
if (aggregation === 'var') {
return Math.round(100 * variance) / 100;
}
if (aggregation === 'std' || aggregation === 'stdev') {
return Math.round(100 * Math.sqrt(variance)) / 100;
}
// fallback to point_count
return count;
};
function drawText(
ctx: CanvasRenderingContext2D,
pixel: [number, number],
compositeOperation: string,
options: DrawTextOptions = {},
) {
const {
fontHeight = 0,
label = '',
radius = 0,
rgb = [0, 0, 0],
shadow = false,
} = options;
const maxWidth = radius * 1.8;
const luminance = luminanceFromRGB(
rgb[1] as number,
rgb[2] as number,
rgb[3] as number,
);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
ctx.font = `${fontHeight}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (shadow) {
ctx.shadowBlur = 15;
ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
}
const textWidth = ctx.measureText(String(label)).width;
if (textWidth > maxWidth) {
const scale = fontHeight / textWidth;
ctx.font = `${scale * maxWidth}px sans-serif`;
}
ctx.fillText(String(label), pixel[0], pixel[1]);
ctx.globalCompositeOperation = compositeOperation as GlobalCompositeOperation;
ctx.shadowBlur = 0;
ctx.shadowColor = '';
}
function ScatterPlotOverlay({
aggregation,
compositeOperation = 'source-over',
dotRadius = 4,
globalOpacity = 1,
lngLatAccessor = defaultLngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging = true,
rgb,
zoom,
}: ScatterPlotOverlayProps) {
const redraw = useCallback(
({ width, height, ctx, isDragging, project }: RedrawParams) => {
const radius = dotRadius;
const clusterLabelMap: (number | string)[] = [];
locations.forEach((location, i) => {
if (location.properties.cluster) {
clusterLabelMap[i] = computeClusterLabel(
location.properties,
aggregation,
);
}
});
const finiteClusterLabels = clusterLabelMap
.map(value => Number(value))
.filter(value => Number.isFinite(value));
const safeMaxAbsLabel =
finiteClusterLabels.length > 0
? Math.max(
Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
1,
)
: 1;
// Calculate min/max radius values for Pixels mode scaling
let minRadiusValue = Infinity;
let maxRadiusValue = -Infinity;
if (pointRadiusUnit === 'Pixels') {
locations.forEach(location => {
if (
!location.properties.cluster &&
location.properties.radius != null
) {
const radiusValueRaw = location.properties.radius;
const radiusValue =
typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
? null
: Number(radiusValueRaw);
if (radiusValue != null && Number.isFinite(radiusValue)) {
minRadiusValue = Math.min(minRadiusValue, radiusValue);
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
}
}
});
}
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation =
compositeOperation as GlobalCompositeOperation;
if ((renderWhileDragging || !isDragging) && locations) {
locations.forEach((location: GeoJSONLocation, i: number) => {
const pixel = project(lngLatAccessor(location));
const pixelRounded: [number, number] = [
roundDecimal(pixel[0], 1),
roundDecimal(pixel[1], 1),
];
if (
pixelRounded[0] + radius >= 0 &&
pixelRounded[0] - radius < width &&
pixelRounded[1] + radius >= 0 &&
pixelRounded[1] - radius < height
) {
ctx.beginPath();
if (location.properties.cluster) {
const clusterLabel = clusterLabelMap[i];
const numericLabel = Number(clusterLabel);
const safeNumericLabel = Number.isFinite(numericLabel)
? numericLabel
: 0;
const minClusterRadius =
pointRadiusUnit === 'Pixels'
? radius * MAX_POINT_RADIUS_RATIO
: radius * MIN_CLUSTER_RADIUS_RATIO;
const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
const scaledRadius = roundDecimal(
minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
1,
);
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
const [x, y] = pixelRounded;
const gradient = ctx.createRadialGradient(
x,
y,
scaledRadius,
x,
y,
0,
);
gradient.addColorStop(
1,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * globalOpacity})`,
);
gradient.addColorStop(
0,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
);
ctx.arc(
pixelRounded[0],
pixelRounded[1],
scaledRadius,
0,
Math.PI * 2,
);
ctx.fillStyle = gradient;
ctx.fill();
if (Number.isFinite(safeNumericLabel)) {
let label: string | number = clusterLabel;
const absLabel = Math.abs(safeNumericLabel);
const sign = safeNumericLabel < 0 ? '-' : '';
if (absLabel >= 10000) {
label = `${sign}${Math.round(absLabel / 1000)}k`;
} else if (absLabel >= 1000) {
label = `${sign}${Math.round(absLabel / 100) / 10}k`;
}
drawText(ctx, pixelRounded, compositeOperation, {
fontHeight,
label,
radius: scaledRadius,
rgb,
shadow: true,
});
}
} else {
const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
const rawRadius = location.properties.radius;
const numericRadiusProperty =
rawRadius != null &&
!(typeof rawRadius === 'string' && rawRadius.trim() === '')
? Number(rawRadius)
: null;
const radiusProperty =
numericRadiusProperty != null &&
Number.isFinite(numericRadiusProperty)
? numericRadiusProperty
: null;
const pointMetric = location.properties.metric ?? null;
let pointRadius: number = radiusProperty ?? defaultRadius;
let pointLabel: string | number | undefined;
if (radiusProperty != null) {
const pointLatitude = lngLatAccessor(location)[1];
if (pointRadiusUnit === 'Kilometers') {
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
pointRadius = kmToPixels(
pointRadius,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Miles') {
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
pointRadius = kmToPixels(
pointRadius * MILES_PER_KM,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Pixels') {
const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
if (
Number.isFinite(minRadiusValue) &&
Number.isFinite(maxRadiusValue) &&
maxRadiusValue > minRadiusValue
) {
const numericPointRadius = Number(pointRadius);
if (!Number.isFinite(numericPointRadius)) {
pointRadius = MIN_POINT_RADIUS;
} else {
const normalizedValueRaw =
(numericPointRadius - minRadiusValue) /
(maxRadiusValue - minRadiusValue);
const normalizedValue = Math.max(
0,
Math.min(1, normalizedValueRaw),
);
pointRadius =
MIN_POINT_RADIUS +
normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
}
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else if (
Number.isFinite(minRadiusValue) &&
minRadiusValue === maxRadiusValue
) {
pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else {
pointRadius = Math.max(
MIN_POINT_RADIUS,
Math.min(pointRadius, MAX_POINT_RADIUS),
);
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
}
}
}
if (pointMetric !== null) {
const numericMetric = parseFloat(String(pointMetric));
pointLabel = Number.isFinite(numericMetric)
? roundDecimal(numericMetric, 2)
: String(pointMetric);
}
if (!pointRadius) {
pointRadius = defaultRadius;
}
ctx.arc(
pixelRounded[0],
pixelRounded[1],
roundDecimal(pointRadius, 1),
0,
Math.PI * 2,
);
ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
ctx.fill();
if (pointLabel !== undefined) {
drawText(ctx, pixelRounded, compositeOperation, {
fontHeight: roundDecimal(pointRadius, 1),
label: pointLabel,
radius: pointRadius,
rgb,
shadow: false,
});
}
}
}
});
}
},
[
aggregation,
compositeOperation,
dotRadius,
globalOpacity,
lngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging,
rgb,
zoom,
],
);
return <CanvasOverlay redraw={redraw} />;
}
export default memo(ScatterPlotOverlay);

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { validateMapboxStylesUrl } from '@superset-ui/core';
import {
columnChoices,
ControlPanelConfig,
@@ -29,12 +28,12 @@ import {
const columnsConfig = sharedControls.entity;
const colorChoices = [
['rgb(0, 139, 139)', t('Dark Cyan')],
['rgb(128, 0, 128)', t('Purple')],
['rgb(255, 215, 0)', t('Gold')],
['rgb(69, 69, 69)', t('Dim Gray')],
['rgb(220, 20, 60)', t('Crimson')],
['rgb(34, 139, 34)', t('Forest Green')],
['#008b8b', t('Dark Cyan')],
['#800080', t('Purple')],
['#ffd700', t('Gold')],
['#454545', t('Dim Gray')],
['#dc143c', t('Crimson')],
['#228b22', t('Forest Green')],
];
const config: ControlPanelConfig = {
@@ -110,7 +109,7 @@ const config: ControlPanelConfig = {
'Either a numerical column or `Auto`, which scales the point based ' +
'on the largest cluster',
),
mapStateToProps: state => {
mapStateToProps: (state: any) => {
const datasourceChoices = columnChoices(state.datasource);
const choices: [string, string][] = [['Auto', t('Auto')]];
return {
@@ -145,7 +144,7 @@ const config: ControlPanelConfig = {
controlSetRows: [
[
{
name: 'mapbox_label',
name: 'map_label',
config: {
type: 'SelectControl',
multi: true,
@@ -157,7 +156,7 @@ const config: ControlPanelConfig = {
'Non-numerical columns will be used to label points. ' +
'Leave empty to get a count of points in each cluster.',
),
mapStateToProps: state => ({
mapStateToProps: (state: any) => ({
choices: columnChoices(state.datasource),
}),
},
@@ -189,21 +188,66 @@ const config: ControlPanelConfig = {
],
},
{
label: t('Visual Tweaks'),
label: t('Map'),
tabOverride: 'customize',
expanded: true,
controlSetRows: [
[
{
name: 'render_while_dragging',
name: 'map_renderer',
config: {
type: 'CheckboxControl',
label: t('Live render'),
default: true,
type: 'SelectControl',
label: t('Map Renderer'),
clearable: false,
renderTrigger: true,
choices: [
['maplibre', t('MapLibre (open-source)')],
['mapbox', t('Mapbox (API key required)')],
],
default: 'maplibre',
description: t(
'Points and clusters will update as the viewport is being changed',
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
),
},
},
],
[
{
name: 'maplibre_style',
config: {
type: 'SelectControl',
label: t('Map Style'),
clearable: false,
renderTrigger: true,
freeForm: true,
choices: [
[
'https://tiles.openfreemap.org/styles/liberty',
t('Liberty (OpenFreeMap)'),
],
[
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
t('Light (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
t('Dark (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
t('Streets (Carto)'),
],
],
default: 'https://tiles.openfreemap.org/styles/liberty',
description: t(
'Base layer map style. See MapLibre documentation: %s',
'https://maplibre.org/maplibre-style-spec/',
),
visibility: ({ controls }: any) =>
controls?.map_renderer?.value !== 'mapbox',
},
},
],
[
{
name: 'mapbox_style',
@@ -213,22 +257,42 @@ const config: ControlPanelConfig = {
clearable: false,
renderTrigger: true,
freeForm: true,
validators: [validateMapboxStylesUrl],
choices: [
['mapbox://styles/mapbox/streets-v9', t('Streets')],
['mapbox://styles/mapbox/dark-v9', t('Dark')],
['mapbox://styles/mapbox/light-v9', t('Light')],
['mapbox://styles/mapbox/streets-v12', t('Streets')],
['mapbox://styles/mapbox/outdoors-v12', t('Outdoors')],
['mapbox://styles/mapbox/light-v11', t('Light')],
['mapbox://styles/mapbox/dark-v11', t('Dark')],
['mapbox://styles/mapbox/satellite-v9', t('Satellite')],
[
'mapbox://styles/mapbox/satellite-streets-v9',
'mapbox://styles/mapbox/satellite-streets-v12',
t('Satellite Streets'),
],
['mapbox://styles/mapbox/satellite-v9', t('Satellite')],
['mapbox://styles/mapbox/outdoors-v9', t('Outdoors')],
],
default: 'mapbox://styles/mapbox/light-v9',
default: 'mapbox://styles/mapbox/light-v11',
description: t(
'Base layer map style. See Mapbox documentation: %s',
'https://docs.mapbox.com/help/glossary/style-url/',
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
),
visibility: ({ controls }: any) =>
controls?.map_renderer?.value === 'mapbox',
},
},
],
],
},
{
label: t('Visual Tweaks'),
tabOverride: 'customize',
controlSetRows: [
[
{
name: 'render_while_dragging',
config: {
type: 'CheckboxControl',
label: t('Live render'),
renderTrigger: true,
default: true,
description: t(
'Points and clusters will update as the viewport is being changed',
),
},
},
@@ -239,9 +303,9 @@ const config: ControlPanelConfig = {
config: {
type: 'TextControl',
label: t('Opacity'),
renderTrigger: true,
default: 1,
isFloat: true,
renderTrigger: true,
description: t(
'Opacity of all clusters, points, and labels. Between 0 and 1.',
),
@@ -250,10 +314,11 @@ const config: ControlPanelConfig = {
],
[
{
name: 'mapbox_color',
name: 'map_color',
config: {
type: 'SelectControl',
freeForm: true,
renderTrigger: true,
label: t('RGB Color'),
default: colorChoices[0][0],
choices: colorChoices,
@@ -278,7 +343,6 @@ const config: ControlPanelConfig = {
isFloat: true,
description: t('Longitude of default viewport'),
places: 8,
// Viewport longitude changes shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -292,7 +356,6 @@ const config: ControlPanelConfig = {
isFloat: true,
description: t('Latitude of default viewport'),
places: 8,
// Viewport latitude changes shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -308,7 +371,6 @@ const config: ControlPanelConfig = {
default: '',
description: t('Zoom level of the map'),
places: 8,
// Viewport zoom shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -325,7 +387,7 @@ const config: ControlPanelConfig = {
),
},
},
formDataOverrides: formData => ({
formDataOverrides: (formData: any) => ({
...formData,
groupby: getStandardizedControls().popAllColumns(),
}),

View File

@@ -28,31 +28,30 @@ import controlPanel from './controlPanel';
const metadata = new ChartMetadata({
category: t('Map'),
credits: ['https://www.mapbox.com/mapbox-gl-js/api/'],
credits: ['https://maplibre.org/'],
description: '',
exampleGallery: [
{ url: example1, urlDark: example1Dark, caption: t('Light mode') },
{ url: example2, urlDark: example2Dark, caption: t('Dark mode') },
],
name: t('MapBox'),
name: t('Point Cluster Map'),
tags: [
t('Business'),
t('Intensity'),
t('Legacy'),
t('Density'),
t('Scatter'),
t('Transformable'),
],
thumbnail,
thumbnailDark,
useLegacyApi: true,
});
export default class MapBoxChartPlugin extends ChartPlugin {
export default class ScatterMapChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./MapBox'),
loadChart: () => import('./MapLibre'),
loadTransformProps: () => import('./transformProps'),
loadBuildQuery: () => import('./buildQuery'),
metadata,
controlPanel,
});

View File

@@ -0,0 +1,176 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable sort-keys, no-magic-numbers */
import { SuperChart } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import ScatterMapChartPlugin from '@superset-ui/plugin-chart-point-cluster-map';
import { withResizableChartDemo } from '@storybook-shared';
import { generateData } from './data';
new ScatterMapChartPlugin().configure({ key: 'point_cluster_map' }).register();
export default {
title: 'Chart Plugins/plugin-chart-point-cluster-map',
decorators: [withResizableChartDemo],
args: {
clusteringRadius: 60,
globalOpacity: 1,
pointRadius: 'Auto',
renderWhileDragging: true,
mapRenderer: 'maplibre',
},
argTypes: {
clusteringRadius: {
control: { type: 'range', min: 0, max: 200, step: 10 },
description: 'Radius in pixels for clustering points',
},
globalOpacity: {
control: { type: 'range', min: 0, max: 1, step: 0.1 },
description: 'Opacity of map markers',
},
pointRadius: {
control: 'select',
options: ['Auto', 1, 2, 5, 10, 20, 50],
description: 'Size of point markers',
},
renderWhileDragging: {
control: 'boolean',
description: 'Render markers while dragging the map',
},
mapRenderer: {
control: 'select',
options: ['maplibre', 'mapbox'],
description:
'Map renderer. MapLibre is open-source. Mapbox requires MAPBOX_API_KEY.',
},
},
};
export const InteractiveSuperclusterMap = ({
clusteringRadius,
globalOpacity,
pointRadius,
renderWhileDragging,
mapRenderer,
width,
height,
}: {
clusteringRadius: number;
globalOpacity: number;
pointRadius: string | number;
renderWhileDragging: boolean;
mapRenderer: string;
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="point_cluster_map"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
clustering_radius: String(clusteringRadius),
global_opacity: globalOpacity,
map_color: '#008b8b',
map_label: [],
map_renderer: mapRenderer,
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
mapbox_style: 'mapbox://styles/mapbox/light-v11',
pandas_aggfunc: 'sum',
point_radius: pointRadius,
point_radius_unit: 'Pixels',
render_while_dragging: renderWhileDragging,
viewport_latitude: 37.78,
viewport_longitude: -122.42,
viewport_zoom: 12,
}}
/>
);
};
export const WithMetricLabels = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="point_cluster_map"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
clustering_radius: '60',
global_opacity: 1,
map_color: '#dc143c',
map_label: ['metric'],
map_renderer: 'maplibre',
maplibre_style:
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
pandas_aggfunc: 'sum',
point_radius: 'Auto',
point_radius_unit: 'Pixels',
render_while_dragging: true,
viewport_latitude: 37.78,
viewport_longitude: -122.42,
viewport_zoom: 12,
}}
/>
);
};
export const NoClustering = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="point_cluster_map"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
clustering_radius: '0',
global_opacity: 0.8,
map_color: '#228b22',
map_label: [],
map_renderer: 'maplibre',
maplibre_style:
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
pandas_aggfunc: 'sum',
point_radius: 'Auto',
point_radius_unit: 'Pixels',
render_while_dragging: true,
viewport_latitude: 37.78,
viewport_longitude: -122.42,
viewport_zoom: 12,
}}
/>
);
};

View File

@@ -0,0 +1,292 @@
/**
* 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 Supercluster, {
type Options as SuperclusterOptions,
} from 'supercluster';
import { ChartProps } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapLibre';
import roundDecimal from './utils/roundDecimal';
const NOOP = () => {};
// Geo precision to limit decimal places (matching legacy backend behavior)
const GEO_PRECISION = 10;
const MIN_LONGITUDE = -180;
const MAX_LONGITUDE = 180;
const MIN_LATITUDE = -90;
const MAX_LATITUDE = 90;
const MIN_ZOOM = 0;
function toFiniteNumber(
value: string | number | null | undefined,
): number | undefined {
if (value === null || value === undefined) return undefined;
const normalizedValue = typeof value === 'string' ? value.trim() : value;
if (normalizedValue === '') return undefined;
const num = Number(normalizedValue);
return Number.isFinite(num) ? num : undefined;
}
function clampNumber(
value: number | undefined,
min: number,
max: number,
): number | undefined {
if (value === undefined) return undefined;
return Math.min(max, Math.max(min, value));
}
interface PointProperties {
metric: number | string | null;
radius: number | string | null;
}
interface ClusterProperties {
metric: number;
sum: number;
squaredSum: number;
min: number;
max: number;
}
interface DataRecord {
[key: string]: string | number | null | undefined;
}
function buildGeoJSONFromRecords(
records: DataRecord[],
lonCol: string,
latCol: string,
labelCol: string | null,
pointRadiusCol: string | null,
) {
const features: GeoJSON.Feature<GeoJSON.Point, PointProperties>[] = [];
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const record of records) {
const rawLon = record[lonCol];
const rawLat = record[latCol];
if (rawLon == null || rawLat == null) {
continue;
}
const lon = Number(rawLon);
const lat = Number(rawLat);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
continue;
}
const roundedLon = roundDecimal(lon, GEO_PRECISION);
const roundedLat = roundDecimal(lat, GEO_PRECISION);
minLon = Math.min(minLon, roundedLon);
maxLon = Math.max(maxLon, roundedLon);
minLat = Math.min(minLat, roundedLat);
maxLat = Math.max(maxLat, roundedLat);
const metric = labelCol != null ? (record[labelCol] ?? null) : null;
const radius =
pointRadiusCol != null ? (record[pointRadiusCol] ?? null) : null;
features.push({
type: 'Feature',
properties: { metric, radius },
geometry: {
type: 'Point',
coordinates: [roundedLon, roundedLat],
},
});
}
const bounds: [[number, number], [number, number]] | undefined =
features.length > 0
? [
[minLon, minLat],
[maxLon, maxLat],
]
: undefined;
return {
geoJSON: { type: 'FeatureCollection' as const, features },
bounds,
};
}
export default function transformProps(chartProps: ChartProps) {
const {
width,
height,
rawFormData: formData,
hooks,
queriesData,
} = chartProps;
const { onError = NOOP, setControlValue = NOOP } = hooks;
const {
all_columns_x: allColumnsX,
all_columns_y: allColumnsY,
clustering_radius: clusteringRadius,
global_opacity: globalOpacity,
map_color: maplibreColor,
map_label: maplibreLabel,
map_renderer: mapProvider,
maplibre_style: maplibreStyle,
mapbox_style: mapboxStyle = '',
pandas_aggfunc: pandasAggfunc,
point_radius: pointRadius,
point_radius_unit: pointRadiusUnit,
render_while_dragging: renderWhileDragging,
viewport_longitude: viewportLongitude,
viewport_latitude: viewportLatitude,
viewport_zoom: viewportZoom,
} = formData;
// Support two data formats:
// 1. Legacy/GeoJSON: queriesData[0].data is an object with { geoJSON, bounds, hasCustomMetric }
// 2. Tabular records: queriesData[0].data is an array of flat records from a SQL query
const rawData = queriesData[0]?.data;
const isLegacyFormat = rawData && !Array.isArray(rawData) && rawData.geoJSON;
let geoJSON: { type: 'FeatureCollection'; features: any[] };
let bounds: [[number, number], [number, number]] | undefined;
let hasCustomMetric: boolean;
if (isLegacyFormat) {
const legacy = rawData as any;
({ geoJSON } = legacy);
({ bounds } = legacy);
hasCustomMetric = legacy.hasCustomMetric ?? false;
} else {
const records: DataRecord[] = (rawData as DataRecord[]) || [];
hasCustomMetric =
maplibreLabel != null &&
maplibreLabel.length > 0 &&
maplibreLabel[0] !== 'count';
const labelCol = hasCustomMetric ? maplibreLabel[0] : null;
const pointRadiusCol =
pointRadius && pointRadius !== 'Auto' ? pointRadius : null;
const built = buildGeoJSONFromRecords(
records,
allColumnsX,
allColumnsY,
labelCol,
pointRadiusCol,
);
({ geoJSON } = built);
({ bounds } = built);
}
// Validate color — supports hex (#rrggbb) and rgb(r, g, b) formats
let rgb: string[] | null = null;
const hexMatch = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(
maplibreColor,
);
if (hexMatch) {
rgb = [
maplibreColor,
String(parseInt(hexMatch[1], 16)),
String(parseInt(hexMatch[2], 16)),
String(parseInt(hexMatch[3], 16)),
];
} else {
rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(maplibreColor);
}
if (rgb === null) {
onError(t("Color field must be a hex color (#rrggbb) or 'rgb(r, g, b)'"));
// Fall back to a safe default color so the chart can still render
rgb = ['', '0', '0', '0'];
}
const opts: SuperclusterOptions<PointProperties, ClusterProperties> = {
maxZoom: DEFAULT_MAX_ZOOM,
radius: clusteringRadius,
};
if (hasCustomMetric) {
opts.map = (prop: PointProperties) => ({
metric: Number(prop.metric) || 0,
sum: Number(prop.metric) || 0,
squaredSum: (Number(prop.metric) || 0) ** 2,
min: Number(prop.metric) || 0,
max: Number(prop.metric) || 0,
});
opts.reduce = (accu: ClusterProperties, prop: ClusterProperties) => {
/* eslint-disable no-param-reassign */
accu.sum += prop.sum;
accu.squaredSum += prop.squaredSum;
accu.min = Math.min(accu.min, prop.min);
accu.max = Math.max(accu.max, prop.max);
/* eslint-enable no-param-reassign */
};
}
const clusterer = new Supercluster<PointProperties, ClusterProperties>(opts);
// Disable strict typecheck on load since Supercluster typings have namespace issues with esModuleInterop
clusterer.load(geoJSON.features as any);
return {
width,
height,
aggregatorName: pandasAggfunc,
bounds,
clusterer,
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
hasCustomMetric,
mapProvider,
mapStyle:
mapProvider === 'mapbox'
? (mapboxStyle as string)
: (maplibreStyle as string),
onViewportChange({
latitude,
longitude,
zoom,
}: {
latitude: number;
longitude: number;
zoom: number;
}) {
setControlValue('viewport_longitude', longitude);
setControlValue('viewport_latitude', latitude);
setControlValue('viewport_zoom', zoom);
},
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit,
renderWhileDragging,
rgb,
viewportLongitude: clampNumber(
toFiniteNumber(viewportLongitude),
MIN_LONGITUDE,
MAX_LONGITUDE,
),
viewportLatitude: clampNumber(
toFiniteNumber(viewportLatitude),
MIN_LATITUDE,
MAX_LATITUDE,
),
viewportZoom: clampNumber(
toFiniteNumber(viewportZoom),
MIN_ZOOM,
DEFAULT_MAX_ZOOM,
),
};
}

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