Compare commits

...
Author SHA1 Message Date
Enzo Martellucci ce0fcf99a1 Merge branch 'master' into enxdev/chat-prototype 2026-06-17 10:05:10 +02:00
a27ec1923e chore(export): Added ability to export chart YAML files with Unicode characters, fix #20331 (#28008)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:55:19 +01:00
3e2174b50f fix(database): enable superset_app_root override for databaseview link (#33508)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-06-16 20:24:49 -07:00
Gabriel Bourgeois 5b66443d48 fix(cli): inconsistent options for set-database-uri (#34893) 2026-06-16 17:50:51 -07:00
2ea7585490 chore(i18n): update German (de) translation (#40431)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:47:57 -07:00
eeac76146c fix(helm): add host alias to init job (#33968)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 17:44:47 -07:00
6a1091d576 fix(sql): broaden mutating-statement detection in SQL Lab parser (#40421)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: sha174n <pedro.sousa@preset.io>
2026-06-16 15:07:34 -07:00
8e82b6b2c3 fix(translation): loading translations in menu (#35640)
Co-authored-by: Jakub Hrubý <jakub.hruby@orgis.cz>
Co-authored-by: Jezevec <panjzvc@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-16 14:35:32 -07:00
b0c5f99007 fix(oracle): replace deprecated cx-Oracle extra with oracledb (#41122)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 14:32:11 -07:00
Elizabeth ThompsonandClaude Sonnet 4.6 f1ae683923 fix(deps): replace deprecated np.NaN with np.nan (#41118)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:19:08 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>EvanClaude Opus 4.8
d51d98891e chore(deps): bump flask-migrate from 3.1.0 to 4.1.0 (#41011)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:18:08 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>EvanClaude Opus 4.8
1f95a6c486 chore(deps): bump simplejson from 3.20.1 to 4.1.1 (#41082)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:00:42 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>EvanClaude Opus 4.8
e93cbd6c38 chore(deps): bump croniter from 6.0.0 to 6.2.2 (#41086)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:59:00 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dca8af770c chore(deps-dev): bump typescript-eslint from 8.60.1 to 8.61.0 in /superset-websocket (#41087)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:58:39 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 81c1181519 chore(deps-dev): bump typescript-eslint from 8.60.1 to 8.61.0 in /docs (#41092)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:56:44 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 387c62919e chore(deps): bump hot-shots from 15.0.0 to 16.0.0 in /superset-websocket (#41107)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:56:22 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 77d7483f27 chore(deps-dev): bump @formatjs/intl-durationformat from 0.10.13 to 0.10.14 in /superset-frontend (#41109)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:54:22 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1a8d08152d chore(deps): bump fuse.js from 7.4.1 to 7.4.2 in /superset-frontend (#41110)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:54:06 -07:00
Bob JoandClaude Fable 5 257dafeec5 fix(query): don't mutate ad-hoc ORDER BY expressions when building queries (#40993)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 13:03:39 -04:00
Michael S. Molina d88a6730cd chore: Chat extension improvements (#41117) 2026-06-16 11:58:36 -03:00
Michael S. Molina ec623f3b93 Merge branch 'master' into enxdev/chat-prototype 2026-06-16 09:28:23 -03:00
Alexandru Soare 6d08e79259 feat(security): Add extension hooks for custom access control, ownership, and asset lifecycle (#40707) 2026-06-16 15:25:03 +03:00
Michael S. Molina 395bbb9611 chore: More cleanup of chat extension code (#41116) 2026-06-16 09:14:08 -03:00
Geidō 01ed81785e fix(dashboard): required filters reliably apply default + Apply enables on change (#40470) 2026-06-16 11:23:05 +03:00
Vighnesh Tulecodeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>Evan Rusackas
7b4efacbc2 fix(charts): add default padding to match other charts (#36895)
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-15 21:05:17 -07:00
Amin GhadersohiandClaude Sonnet 4.6 7cb4990403 feat(mcp): add create_dataset tool to register physical tables as datasets (#40340)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:25:29 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c90b2571d7 chore(deps-dev): bump xlrd from 2.0.1 to 2.0.2 (#41083)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:19:43 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1a4941eee5 chore(deps-dev): bump hdbcli from 2.28.20 to 2.28.21 (#41084)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:19:33 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Superset DevClaude Fable 5
d839cca995 chore(deps-dev): update pyocient requirement from <2,>=1.0.15 to >=1.0.15,<4 (#40941)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 18:18:25 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0ec7e7df99 chore(deps): bump dompurify from 3.4.8 to 3.4.9 in /superset-frontend (#41089)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:36 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9d8287e1bd chore(deps-dev): bump @typescript-eslint/parser from 8.60.1 to 8.61.0 in /superset-websocket (#41090)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:21 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0c696cea7e chore(deps): bump google-auth-library from 10.6.2 to 10.7.0 in /superset-frontend (#41091)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:05 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fe625a917e chore(deps-dev): bump @typescript-eslint/parser from 8.60.1 to 8.61.0 in /docs (#41093)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:14:51 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a69f9eb00d chore(deps-dev): bump oxlint from 1.68.0 to 1.69.0 in /superset-frontend (#41094)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:14:27 -07:00
Evan RusackasandClaude Opus 4.5 1311d040ba feat(deckgl): add point radius controls for GeoJSON layer (#33247)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-15 17:38:43 -07:00
6e2db42d98 chore(lint): convert dashboard components to function components (#39460)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-06-15 16:39:12 -07:00
yousoph 28aedc82c3 fix(upload): database field shows validation warning after selecting a database (#41078) 2026-06-15 16:38:24 -07:00
f56524bb71 chore(frontend): remove unused modules flagged by knip (#41072)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:38:00 -07:00
4ae9980e4c chore(ci): remove unused Claude PR Assistant workflow (#41081)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:37:39 -07:00
Amin Ghadersohi c1b5d05f83 fix(bigquery): set default dataset from schema in adjust_engine_params (#40776) 2026-06-15 18:37:06 -04:00
Evan Rusackasdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Claude Fable 5
e16bb29faf fix(embedded): allow guests to apply a Time Grain native filter (#32768) (#41017)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 15:22:21 -07:00
Elizabeth Thompson 09b4bc51a3 fix(charts): rename deprecated query object fields in schema before QueryObject construction (#41056) 2026-06-15 14:45:41 -07:00
Evan RusackasandClaude Code 379435b7eb feat(ssh_tunnel): add opt-in server host key verification (#40673)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-15 12:17:14 -07:00
Amin GhadersohiandCopilot Autofix powered by AI 4b96b91b53 feat(mcp): add aggregation field to BigNumberChartConfig for Big Number with Trendline (#40775)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 14:40:37 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 073599bd0c chore(deps-dev): bump @babel/core from 7.25.2 to 7.29.6 in /superset-embedded-sdk (#41057)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:20:01 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9e2c4533c8 chore(deps): bump @babel/core from 7.28.6 to 7.29.7 in /docs (#41058)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:19:43 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1ae115981f chore(deps): bump form-data from 4.0.5 to 4.0.6 in /superset-frontend/cypress-base (#41061)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:19:23 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b078ae4b51 chore(deps): bump launch-editor from 2.11.1 to 2.14.1 in /docs (#41062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:19:05 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 48b755470f chore(deps): bump form-data from 4.0.5 to 4.0.6 in /superset-frontend (#41063)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:18:48 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dad7dae4f6 chore(deps): bump launch-editor from 2.9.1 to 2.14.1 in /superset-frontend (#41065)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:18:20 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>EvanClaude Opus 4.8
11e35eca3b chore(deps-dev): bump eslint from 8.57.1 to 10.4.1 in /superset-frontend (#40846)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:36:50 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8093197c97 chore(deps): bump baseline-browser-mapping from 2.10.33 to 2.10.34 in /docs (#41036)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:31:50 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 919c6eddc1 chore(deps-dev): bump @types/node from 25.9.1 to 25.9.2 in /superset-websocket (#41032)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:22:14 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 886bb200d0 chore(deps): bump react-arborist from 3.8.0 to 3.10.1 in /superset-frontend (#41033)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:21:54 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>EvanClaude Opus 4.8
b5ca00d06b chore(deps-dev): bump @types/node from 25.9.3 to 25.9.2 in /superset-frontend/packages/superset-ui-core (#41035)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:21:11 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5719f8e349 chore(deps-dev): bump @types/node from 25.9.1 to 25.9.2 in /superset-frontend (#41037)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:20:27 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9d72a39e10 chore(deps): bump caniuse-lite from 1.0.30001793 to 1.0.30001797 in /docs (#41038)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:20:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 66733a5d72 chore(deps): bump codecov/codecov-action from 6.0.1 to 7.0.0 (#41039)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:19:52 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a435002293 chore(deps-dev): bump @types/jquery from 4.0.0 to 4.0.1 in /superset-frontend (#41040)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:19:29 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2d8447af42 chore(deps-dev): bump baseline-browser-mapping from 2.10.33 to 2.10.34 in /superset-frontend (#41041)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:19:12 -07:00
Minh Vu bf5daf0a1e fix(mcp): redirect stdio click echo without recursion (#40814) 2026-06-15 10:10:40 -07:00
Joe LiandClaude Opus 4.8 b656b1d477 fix(reports): escape LIKE wildcards in text filter and preserve typed screenshot width (#40980)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:59:40 -07:00
Joe LiandClaude Opus 4.7 5a97e01d6e test(dashboard): migrate standalone mode Cypress spec to RTL (#40914)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 09:57:24 -07:00
Joe LiandClaude Opus 4.7 38cc70de2f test(chart): URL params land on each query in chart-data request (#40913)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 09:57:04 -07:00
Richard Fogaca Nienkotter a1bc3c67ed fix(embedded): skip CSRF token fetch for guest streaming chart exports (#41004) 2026-06-15 13:49:25 -03:00
Evan RusackasandClaude Code e5b6642b18 test(pivot-table): embedded E2E that collapse state survives scrolling (#33406) (#40820)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-15 08:53:14 -07:00
Enzo Martellucci 5c1609e3f9 chore(extensions): align naming conventions 2026-06-15 17:46:42 +02:00
Evan RusackasandClaude Code dd3a61156b fix(handlebars): register a dayjs-backed formatDate helper (#32960) (#40817)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-15 08:40:08 -07:00
Amin Ghadersohi 820e3d18d3 fix(mcp): make search_tools query optional to fix null content on tool discovery (#40906) 2026-06-15 11:32:40 -04:00
Mehmet Salih YavuzandClaude Opus 4.7 2dd8fe362f fix(DynamicEditableTitle): preserve in-flight edits when title prop changes (#39861)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 18:14:18 +03:00
Evan RusackasandClaude Code 9d2f625e55 feat(encrypt): selectable encryption engine + AES-GCM re-encryption migrator (#40654)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-15 08:10:08 -07:00
Đỗ Trọng Hải d66895c566 build(dev-deps): update webpack-related packages to next major version (#41022) 2026-06-15 21:57:55 +07:00
Enzo Martellucci 715c07b5c7 chore(extensions): remove out-of-scope scaffolding from chat SIP branch 2026-06-15 15:27:24 +02:00
Pat Buxton 2734bde504 fix(chart): Allow Admin non-owner to save chart (#37175) 2026-06-14 21:28:36 -07:00
Nitish Agarwal f88f1fad04 fix(table): sort metric columns numerically regardless of display format (#39775) 2026-06-14 21:14:44 -07:00
Kim Jeong JuJeong Ju, Kim <haje01@webzen.com>EvanClaude Opus 4.8
9ec9c8658b fix(query_context_processor): apply CSV encoding to follow the encoding settings of 'CSV_EXPORT' (#34806)
Co-authored-by: Jeong Ju, Kim <haje01@webzen.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:55:25 -07:00
Evan Rusackas 9f81565625 feat(uploads): add optional max file size for data uploads (#40860) 2026-06-14 19:46:48 +07:00
c934498696 fix(dashboard): do not add importer as owner when overwriting existing dashboard (#38615)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 05:10:03 -07:00
Michael S. Molina a1eba0f9a1 chore: Cleanup changes in chat feature branch (#41008) 2026-06-12 16:58:21 -03:00
Enzo Martellucci 568337f370 feat(extensions): add dedicated chat contribution type (#41000) 2026-06-12 20:54:13 +02:00
Enzo Martellucci f170dc1d9e refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot (#40968) 2026-06-11 13:32:03 +02:00
Enzo Martellucci 09c09f3f6b refactor: rename chatbot registration location 2026-06-10 14:32:52 +02:00
Enzo Martellucci c65c9523aa refactor(extensions): remove install/lifecycle/dependency machinery (#40916) 2026-06-09 22:20:40 +02:00
Enzo MartellucciandClaude Opus 4.8 94e0071883 Merge branch 'master' into enxdev/chat-prototype
Bring the chatbot extension feature branch up to date with master. The
chatbot work lives in new paths (superset/extensions/*, the core chatbot
namespace, ChatbotMount, superset-core namespaces) and merged cleanly with
no conflicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:55:53 +02:00
Enzo Martellucci 380e70060b feat(extensions): define the superset.chatbot contribution point (#40439) 2026-06-08 22:50:34 +02:00
231 changed files with 15326 additions and 10053 deletions
-4
View File
@@ -3,10 +3,6 @@ enable-beta-ecosystems: true
updates:
- package-ecosystem: "github-actions"
directory: "/"
ignore:
# Ignore temporarily as release schedule is too mentally taxing for dep-handling maintainers
# Additionally, very few PRs are reviewed by this action.
- dependency-name: anthropics/claude-code-action
schedule:
interval: "daily"
cooldown:
-88
View File
@@ -1,88 +0,0 @@
name: Claude PR Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
permissions:
contents: read
jobs:
check-permissions:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
runs-on: ubuntu-latest
outputs:
allowed: ${{ steps.check.outputs.allowed }}
steps:
- name: Check if user is allowed
id: check
env:
COMMENTER: ${{ github.event.comment.user.login }}
run: |
# List of allowed users
ALLOWED_USERS="mistercrunch,rusackas"
echo "Checking permissions for user: $COMMENTER"
# Check if user is in allowed list
if [[ ",$ALLOWED_USERS," == *",$COMMENTER,"* ]]; then
echo "allowed=true" >> $GITHUB_OUTPUT
echo "✅ User $COMMENTER is allowed to use Claude"
else
echo "allowed=false" >> $GITHUB_OUTPUT
echo "❌ User $COMMENTER is not allowed to use Claude"
fi
deny-access:
needs: check-permissions
if: needs.check-permissions.outputs.allowed == 'false'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Comment access denied
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENTER_LOGIN: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
with:
script: |
const commenter = process.env.COMMENTER_LOGIN;
const message = `👋 Hi @${commenter}!
Thanks for trying to use Claude Code, but currently only certain team members have access to this feature.
If you believe you should have access, please contact a project maintainer.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
claude-code-action:
needs: check-permissions
if: needs.check-permissions.outputs.allowed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@5fb899572b81d2bb648d4d187173a2f423a9677c # beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"
@@ -56,7 +56,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: steps.check.outputs.superset-extensions-cli
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
file: ./coverage.xml
flags: superset-extensions-cli
+1 -1
View File
@@ -134,7 +134,7 @@ jobs:
run: npx nyc merge coverage/ merged-output/coverage-summary.json
- name: Upload Code Coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: javascript
use_oidc: true
@@ -85,7 +85,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: python,mysql
verbose: true
@@ -173,7 +173,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: python,postgres
verbose: true
@@ -222,7 +222,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: python,sqlite
verbose: true
@@ -90,7 +90,7 @@ jobs:
run: |
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: python,presto
verbose: true
@@ -152,7 +152,7 @@ jobs:
pip install -e .[hive]
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: python,hive
verbose: true
@@ -72,7 +72,7 @@ jobs:
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: python,unit
verbose: true
+48
View File
@@ -67,6 +67,10 @@ Operators can tune or disable the policy via config:
- `FAB_PASSWORD_COMPLEXITY_VALIDATOR` — replace with your own callable for custom rules.
- `FAB_PASSWORD_COMPLEXITY_ENABLED = False` — disable enforcement entirely.
### Data uploads bounded by UPLOAD_MAX_FILE_SIZE_BYTES
Single data-file uploads (CSV, Excel, columnar) are now bounded by the `UPLOAD_MAX_FILE_SIZE_BYTES` config option, which defaults to `100 * 1024 * 1024` (100 MB). Files larger than this are rejected with a `413` before their contents are buffered into memory. Set `UPLOAD_MAX_FILE_SIZE_BYTES = None` to disable the check and restore unbounded uploads.
### Duration formatter precision
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.
@@ -117,6 +121,20 @@ This change is backward compatible. The feature is off by default, and even when
Disabling a user account (setting `active` to `False`, via the admin UI, REST API, or CLI) now terminates that user's outstanding sessions on their next request, instead of relying on a passive check. This works for both client-side cookie sessions and server-side session stores via a per-user invalidation epoch (`user_attribute.sessions_invalidated_at`, added by a migration). The mechanism is inert for users that were never disabled (NULL epoch), so there is no behavior change for active users. Re-enabling an account and logging in again starts a fresh, valid session. The migration backfills the epoch for accounts that are already disabled at upgrade time, so re-enabling such an account does not revive a session that predates this feature.
### Opt-in SSH tunnel server host key verification
SSH tunnels can now optionally pin the expected SSH server host key as a defense-in-depth measure against man-in-the-middle attacks. paramiko's transport performs no known-hosts checking by default, so previously the SSH server's identity was not verified. This feature is opt-in and off by default; existing tunnels are unaffected.
- A new nullable `server_host_key` column on the `ssh_tunnels` table stores the expected host key in authorized-key form (e.g. `ssh-ed25519 AAAA...`). It is a public key and is stored in plaintext. It can be set via the SSH tunnel POST/PUT payloads (`ssh_tunnel.server_host_key`).
- When a tunnel has `server_host_key` set, Superset connects to the SSH server, reads the host key it presents, and rejects the tunnel if it does not match.
- A new config flag `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING` (default `False`) controls fail-closed behavior. When `True`, every tunnel must declare a `server_host_key`; a tunnel without one is rejected.
Runbook to adopt:
1. Capture the SSH server's host key, e.g. `ssh-keyscan -t ed25519 ssh.example.com` (verify it out-of-band).
2. Set that value on the tunnel's `server_host_key` (via the database/SSH tunnel API or UI payload).
3. Optionally set `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING = True` in `superset_config.py` to require host-key verification on all tunnels.
### Dataset import validates catalog against the target connection
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
@@ -136,6 +154,36 @@ Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENS
The Dynamic Group By chart customization now orders its display values according to the "Sort display control values" toggle: ascending (AZ), descending (ZA), or the dataset's source order when the toggle is unset. Previously the dropdown always sorted alphabetically. Existing dashboards where the toggle was never set will show options in source order instead of AZ; open the customization and enable the toggle to restore alphabetical ordering.
### Selectable encryption engine for app-encrypted fields (AES-GCM)
App-encrypted fields (database passwords, SSH tunnel credentials, OAuth tokens, etc.) can now use authenticated **AES-GCM** encryption instead of the historical unauthenticated **AES-CBC**. A new config selects the engine for the default adapter:
```python
# "aes" (AES-CBC, historical default) | "aes-gcm" (authenticated, recommended for new installs)
SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes"
```
**No action required / no behavior change:** the default remains `"aes"`, so existing installs are unaffected.
**Opting in on an existing install:** flipping the engine on a populated database without re-encrypting first will make stored secrets undecryptable, because the two ciphertext formats are not compatible. A migrator is provided. Recommended runbook:
1. Take a metadata-DB backup.
2. Re-encrypt existing secrets into the new engine (the `SECRET_KEY` is unchanged):
```bash
superset re-encrypt-secrets --engine aes-gcm
```
3. Set `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm"` in your config.
4. Restart Superset.
5. Re-run the migrator once more after the restart:
```bash
superset re-encrypt-secrets --engine aes-gcm
```
A live instance keeps writing *new* secrets as AES-CBC during the window between step 2 and the restart in step 4; this second pass sweeps those up (it is idempotent, so already-migrated values are skipped).
Schedule the cutover in a quiet window. Runtime reads use only the single configured engine, so in a multi-worker deployment there is an unavoidable brief decrypt-outage between the migration commit and the last worker restarting with the new config — each migrator run is transactional, but the fleet-wide cutover is not zero-downtime.
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
### Granular Export Controls
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
+4 -4
View File
@@ -72,8 +72,8 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.40",
"antd": "^6.4.3",
"baseline-browser-mapping": "^2.10.33",
"caniuse-lite": "^1.0.30001793",
"baseline-browser-mapping": "^2.10.34",
"caniuse-lite": "^1.0.30001797",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
"js-yaml": "^4.2.0",
@@ -101,7 +101,7 @@
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.60.1",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
@@ -109,7 +109,7 @@
"globals": "^17.6.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.60.1",
"typescript-eslint": "^8.61.0",
"webpack": "^5.107.2"
},
"browserslist": {
@@ -0,0 +1,136 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# SIP: Authenticated encryption (AES-GCM) for app-encrypted fields
## [DRAFT — proposal for discussion]
This document is a draft proposal accompanying the code in this PR. It is
intended to seed the formal SIP discussion. The code here ships the
backward-compatible engine selection **and** the re-encryption migrator
(Phases 12 below); both are opt-in and change nothing for existing installs by
default. Flipping the default for fresh installs (Phase 3) remains future work.
## Motivation
Superset app-encrypts a number of sensitive fields before persisting them to
the metadata database, including:
- database connection passwords and `encrypted_extra` (`superset/models/core.py`),
- SSH tunnel credentials — password, private key, private-key password
(`superset/databases/ssh_tunnel/models.py`),
- OAuth2 tokens and other secrets stored via `EncryptedType`.
These fields are encrypted with `sqlalchemy_utils.EncryptedType`, which
**defaults to `AesEngine` (AES-CBC)**. AES-CBC provides confidentiality but is
**unauthenticated**: it has no integrity tag. An attacker with write access to
the ciphertext (e.g. direct metadata-DB access, a backup, or a compromised
replica) can perform **bit-flipping / chosen-ciphertext manipulation** to
silently alter the decrypted plaintext of a secret without detection.
`AesGcmEngine` (AES-GCM) is authenticated encryption: tampering causes
decryption to fail loudly rather than yielding attacker-influenced plaintext.
Using authenticated encryption for secrets at rest is an ASVS L1 expectation
(11.3.2 / cryptography best practice).
`config.py` already documents that operators *can* switch to GCM by writing a
custom `AbstractEncryptedFieldAdapter`, but:
1. it is opt-in, undocumented as a security recommendation, and easy to miss;
2. there is **no migration path** — flipping the engine on a populated database
makes every existing secret undecryptable, because GCM ciphertext is not
format-compatible with CBC.
## Proposed change
A three-part change, delivered incrementally so existing deployments are never
broken:
### Phase 1 — engine selection (this PR)
- Add a `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE` config (`"aes"` | `"aes-gcm"`),
**defaulting to `"aes"`** (no behavior change for existing installs).
- Teach the default `SQLAlchemyUtilsAdapter` to honor it (an explicit `engine`
kwarg still wins, so the migrator can pin an engine).
- This lets **new** deployments choose AES-GCM from day one with a one-line
config, instead of writing a custom adapter.
### Phase 2 — CBC→GCM re-encryption migrator (this PR)
The existing `SecretsMigrator` (previously only used for `SECRET_KEY` rotation)
gains an **engine migration** mode that:
1. discovers every `EncryptedType` column (via `discover_encrypted_fields()`),
2. decrypts each value with the **source** engine (AES-CBC) under the current
`SECRET_KEY`,
3. re-encrypts with the **target** engine (AES-GCM),
4. runs transactionally per the existing all-or-nothing semantics, and is
idempotent per column (already-migrated values are skipped), so a run can be
safely repeated or resumed.
Exposed via a new `--engine` option on the existing CLI command:
`superset re-encrypt-secrets --engine aes-gcm`, runnable by operators with a DB
backup in hand. The `SECRET_KEY` is unchanged; an engine change and a key
rotation can also be combined (pass `--previous_secret_key` as well).
### Phase 3 — flip the default for new installs
Once the migrator and docs are in place, change the default to `"aes-gcm"` for
**fresh** installs only (e.g. keyed off an empty metadata DB / documented in
`UPDATING.md`), keeping existing installs on `"aes"` until they run Phase 2.
## New or changed public interfaces
- New config: `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"]`.
- New (Phase 2) CLI: `superset re-encrypt-secrets --engine <name>`.
- No schema changes; ciphertext format changes per migrated column.
## Migration plan and compatibility
- **Backward compatible by default.** Phase 1 changes nothing unless the
operator opts in.
- Switching an existing deployment to `"aes-gcm"` **without** running the Phase
2 migrator will make existing secrets undecryptable — this is called out in
the config comment and must be in `UPDATING.md`.
- Recommended operator runbook: take a metadata-DB backup → run
`re-encrypt-secrets --engine aes-gcm` → set
`SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm"` → restart → re-run
`re-encrypt-secrets --engine aes-gcm` once more to sweep up any secrets a live
instance wrote as AES-CBC during the cutover window. The canonical, more
detailed version of this runbook lives in `UPDATING.md`; this is a summary.
- `AesEngine` allows queryability over encrypted fields; AES-GCM does not.
Any code that filters/queries on an encrypted column directly must be audited
before Phase 3 (none is expected, but it must be verified).
## Rejected alternatives
- **Flip the default immediately.** Rejected: bricks every existing
deployment's secrets with no migration path.
- **Document-only (custom adapter).** Status quo; high friction and no
migration tooling — most operators will never do it.
## Open questions
- GCM→CBC rollback (for operators who need queryability) already works via the
same command (`re-encrypt-secrets --engine aes`), since the migrator is
engine-symmetric. Should rollback be documented as a supported path or
discouraged?
- The migrator already supports a concurrent `SECRET_KEY` rotation + engine
change in a single pass (pass `--previous_secret_key` alongside `--engine`).
Is that combination worth calling out in the operator docs, or kept advanced?
+2 -2
View File
@@ -7235,10 +7235,10 @@
"pypi_packages": [
"oracledb"
],
"connection_string": "oracle://{username}:{password}@{hostname}:{port}",
"connection_string": "oracle+oracledb://{username}:{password}@{hostname}:{port}",
"default_port": 1521,
"notes": "Previously used cx_Oracle, now uses oracledb.",
"docs_url": "https://cx-oracle.readthedocs.io/en/latest/user_guide/installation.html",
"docs_url": "https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html",
"category": "Other Databases"
},
"engine": "oracle",
+217 -107
View File
@@ -265,6 +265,15 @@
js-tokens "^4.0.0"
picocolors "^1.1.1"
"@babel/code-frame@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7"
integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==
dependencies:
"@babel/helper-validator-identifier" "^7.29.7"
js-tokens "^4.0.0"
picocolors "^1.1.1"
"@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0":
version "7.28.0"
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz"
@@ -275,20 +284,25 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz"
integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==
"@babel/compat-data@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629"
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
"@babel/core@^7.21.3", "@babel/core@^7.25.9":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz"
integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7"
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
dependencies:
"@babel/code-frame" "^7.28.6"
"@babel/generator" "^7.28.6"
"@babel/helper-compilation-targets" "^7.28.6"
"@babel/helper-module-transforms" "^7.28.6"
"@babel/helpers" "^7.28.6"
"@babel/parser" "^7.28.6"
"@babel/template" "^7.28.6"
"@babel/traverse" "^7.28.6"
"@babel/types" "^7.28.6"
"@babel/code-frame" "^7.29.7"
"@babel/generator" "^7.29.7"
"@babel/helper-compilation-targets" "^7.29.7"
"@babel/helper-module-transforms" "^7.29.7"
"@babel/helpers" "^7.29.7"
"@babel/parser" "^7.29.7"
"@babel/template" "^7.29.7"
"@babel/traverse" "^7.29.7"
"@babel/types" "^7.29.7"
"@jridgewell/remapping" "^2.3.5"
convert-source-map "^2.0.0"
debug "^4.1.0"
@@ -318,6 +332,17 @@
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
"@babel/generator@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3"
integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==
dependencies:
"@babel/parser" "^7.29.7"
"@babel/types" "^7.29.7"
"@jridgewell/gen-mapping" "^0.3.12"
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3":
version "7.27.3"
resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz"
@@ -325,7 +350,7 @@
dependencies:
"@babel/types" "^7.27.3"
"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2", "@babel/helper-compilation-targets@^7.28.6":
"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz"
integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==
@@ -336,6 +361,17 @@
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-compilation-targets@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042"
integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==
dependencies:
"@babel/compat-data" "^7.29.7"
"@babel/helper-validator-option" "^7.29.7"
browserslist "^4.24.0"
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-create-class-features-plugin@^7.27.1", "@babel/helper-create-class-features-plugin@^7.28.3":
version "7.28.3"
resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz"
@@ -374,6 +410,11 @@
resolved "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz"
integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
"@babel/helper-globals@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b"
integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==
"@babel/helper-member-expression-to-functions@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz"
@@ -398,6 +439,14 @@
"@babel/traverse" "^7.28.6"
"@babel/types" "^7.28.6"
"@babel/helper-module-imports@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396"
integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==
dependencies:
"@babel/traverse" "^7.29.7"
"@babel/types" "^7.29.7"
"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.6":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz"
@@ -407,6 +456,15 @@
"@babel/helper-validator-identifier" "^7.28.5"
"@babel/traverse" "^7.28.6"
"@babel/helper-module-transforms@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae"
integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==
dependencies:
"@babel/helper-module-imports" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@babel/traverse" "^7.29.7"
"@babel/helper-optimise-call-expression@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz"
@@ -455,16 +513,31 @@
resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-string-parser@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f"
integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==
"@babel/helper-validator-identifier@^7.28.5":
version "7.28.5"
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz"
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
"@babel/helper-validator-identifier@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2"
integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==
"@babel/helper-validator-option@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz"
integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
"@babel/helper-validator-option@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a"
integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==
"@babel/helper-wrap-function@^7.27.1":
version "7.28.3"
resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz"
@@ -474,13 +547,13 @@
"@babel/traverse" "^7.28.3"
"@babel/types" "^7.28.2"
"@babel/helpers@^7.28.6":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz"
integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==
"@babel/helpers@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607"
integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==
dependencies:
"@babel/template" "^7.28.6"
"@babel/types" "^7.28.6"
"@babel/template" "^7.29.7"
"@babel/types" "^7.29.7"
"@babel/parser@^7.28.6":
version "7.28.6"
@@ -496,6 +569,13 @@
dependencies:
"@babel/types" "^7.29.0"
"@babel/parser@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334"
integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==
dependencies:
"@babel/types" "^7.29.7"
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz"
@@ -1172,6 +1252,15 @@
"@babel/parser" "^7.28.6"
"@babel/types" "^7.28.6"
"@babel/template@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==
dependencies:
"@babel/code-frame" "^7.29.7"
"@babel/parser" "^7.29.7"
"@babel/types" "^7.29.7"
"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.6":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz"
@@ -1198,6 +1287,19 @@
"@babel/types" "^7.29.0"
debug "^4.3.1"
"@babel/traverse@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d"
integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==
dependencies:
"@babel/code-frame" "^7.29.7"
"@babel/generator" "^7.29.7"
"@babel/helper-globals" "^7.29.7"
"@babel/parser" "^7.29.7"
"@babel/template" "^7.29.7"
"@babel/types" "^7.29.7"
debug "^4.3.1"
"@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.4.4":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz"
@@ -1214,6 +1316,14 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
"@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@braintree/sanitize-url@^7.1.1":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz#ca2035b0fefe956a8676ff0c69af73e605fcd81f"
@@ -4812,110 +4922,110 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.60.1", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz#c1060bb8fa4be80624d3f3dec8dd9caca373af76"
integrity sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==
"@typescript-eslint/eslint-plugin@8.61.0", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz#db20271974b94a3a54d3b9544e5f5b3481448400"
integrity sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/type-utils" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/type-utils" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.60.1", "@typescript-eslint/parser@^8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289"
integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==
"@typescript-eslint/parser@8.61.0", "@typescript-eslint/parser@^8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.0.tgz#1afe73c9ccce16b7a26d6b95f9400b0ccc34af87"
integrity sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==
dependencies:
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b"
integrity sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==
"@typescript-eslint/project-service@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.0.tgz#417a2feac32e8ebd336d63f068c3b42b736ea1ac"
integrity sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.60.1"
"@typescript-eslint/types" "^8.60.1"
"@typescript-eslint/tsconfig-utils" "^8.61.0"
"@typescript-eslint/types" "^8.61.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8"
integrity sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==
"@typescript-eslint/scope-manager@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz#93c2520d05653fe65eb9ee98efc74fd0134a7852"
integrity sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==
dependencies:
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
"@typescript-eslint/tsconfig-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
"@typescript-eslint/tsconfig-utils@^8.60.1":
"@typescript-eslint/tsconfig-utils@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz#05d6e3ff20001674ebcd22d03dac29ee448043ba"
integrity sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==
"@typescript-eslint/type-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379"
integrity sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==
"@typescript-eslint/tsconfig-utils@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz#ca88080e0cf191d49516d7f300b67aa090d2254f"
integrity sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==
"@typescript-eslint/type-utils@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz#50219b57e6b89cecfb1a15f093b15ec9ee019974"
integrity sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==
dependencies:
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
"@typescript-eslint/types@^8.60.1":
"@typescript-eslint/types@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d"
integrity sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==
"@typescript-eslint/typescript-estree@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74"
integrity sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==
"@typescript-eslint/types@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.1.tgz#0c51f518e4e6848371a1c988e859d59eb7522d5a"
integrity sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==
"@typescript-eslint/typescript-estree@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz#98ca47260bbf627fc28f018b3a0abf00e3090690"
integrity sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==
dependencies:
"@typescript-eslint/project-service" "8.60.1"
"@typescript-eslint/tsconfig-utils" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/project-service" "8.61.0"
"@typescript-eslint/tsconfig-utils" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b"
integrity sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==
"@typescript-eslint/utils@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.61.0.tgz#ed3546a052787e84ea6c5064d0919fc5eea8522f"
integrity sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/visitor-keys@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz#165d1d8901137b944efaf18f00ab5ecb57f06995"
integrity sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==
"@typescript-eslint/visitor-keys@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz#39b4e1ab8936d23bea973d39fd092f9aa21f275e"
integrity sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==
dependencies:
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/types" "8.61.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -5578,10 +5688,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.33, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.33"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz#27c299b096404978831958d429f48390424c4f9b"
integrity sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==
baseline-browser-mapping@^2.10.34, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.34"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz#dedb606362446777cfe328d30d4ee15056d06303"
integrity sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==
batch@0.6.1:
version "0.6.1"
@@ -5824,10 +5934,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.30001793:
version "1.0.30001793"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz#238887ddf5fcfc8c36d872394d0a78a517312a72"
integrity sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001797:
version "1.0.30001797"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz#1332709e1439f01ff92085dd17001e0a45897ec0"
integrity sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==
ccount@^2.0.0:
version "2.0.1"
@@ -9532,12 +9642,12 @@ latest-version@^7.0.0:
package-json "^8.1.0"
launch-editor@^2.6.1:
version "2.11.1"
resolved "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz"
integrity sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==
version "2.14.1"
resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.14.1.tgz#f7e0da3f58aaea03fea01074d840b5f739ed7ddc"
integrity sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==
dependencies:
picocolors "^1.1.1"
shell-quote "^1.8.3"
shell-quote "^1.8.4"
layout-base@^1.0.0:
version "1.0.2"
@@ -13489,7 +13599,7 @@ shebang-regex@^3.0.0:
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
shell-quote@^1.8.3:
shell-quote@^1.8.4:
version "1.8.4"
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.4.tgz#2edd9a4dcefc96649e2e2cb12f637b1f1d92a190"
integrity sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==
@@ -14389,15 +14499,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.60.1:
version "8.60.1"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.1.tgz#13db05c6eabb89669deec44545b788a0e9aee640"
integrity sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==
typescript-eslint@^8.61.0:
version "8.61.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.61.0.tgz#6927fb94f5f29623e370d33fd9fa61f15d6d996b"
integrity sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==
dependencies:
"@typescript-eslint/eslint-plugin" "8.60.1"
"@typescript-eslint/parser" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/eslint-plugin" "8.61.0"
"@typescript-eslint/parser" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
typescript@~6.0.3:
version "6.0.3"
+1 -1
View File
@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.16.1 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27
+1 -1
View File
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.16.0](https://img.shields.io/badge/Version-0.16.0-informational?style=flat-square)
![Version: 0.16.1](https://img.shields.io/badge/Version-0.16.1-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
+3
View File
@@ -62,6 +62,9 @@ spec:
{{- if .Values.init.initContainers }}
initContainers: {{- tpl (toYaml .Values.init.initContainers) . | nindent 6 }}
{{- end }}
{{- with .Values.hostAliases }}
hostAliases: {{- toYaml . | nindent 6 }}
{{- end }}
containers:
- name: {{ template "superset.name" . }}-init-db
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+7 -7
View File
@@ -43,7 +43,7 @@ dependencies = [
"click-option-group",
"colorama",
"flask-cors>=6.0.0, <7.0",
"croniter>=0.3.28",
"croniter>=6.2.2",
"cron-descriptor",
"cryptography>=42.0.4, <47.0.0",
"deprecation>=2.1.0, <2.2.0",
@@ -53,7 +53,7 @@ dependencies = [
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
"flask-migrate>=3.1.0, <5.0",
"flask-migrate>=4.1.0, <5.0",
"flask-session>=0.4.0, <1.0",
"flask-wtf>=1.3.0, <2.0",
"geopy",
@@ -97,7 +97,7 @@ dependencies = [
"selenium>=4.44.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=3.15.0",
"simplejson>=4.1.1",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
@@ -144,7 +144,7 @@ dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
excel = ["xlrd>=1.2.0, <1.3"]
excel = ["xlrd>=2.0.2, <2.1"]
fastmcp = [
"fastmcp>=3.2.4,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
@@ -156,7 +156,7 @@ firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=26.4.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
hana = ["hdbcli==2.28.21", "sqlalchemy_hana==0.4.0"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",
@@ -173,11 +173,11 @@ motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.1.0, <3"]
ocient = [
"sqlalchemy-ocient>=1.0.0",
"pyocient>=1.0.15, <2",
"pyocient>=1.0.15, <4",
"shapely",
"geojson",
]
oracle = ["cx-Oracle>8.0.0, <8.4"]
oracle = ["oracledb>=2.0.0, <5"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.60.0, <2"]
+3 -3
View File
@@ -84,7 +84,7 @@ colorama==0.4.6
# flask-appbuilder
cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.0.0
croniter==6.2.2
# via apache-superset (pyproject.toml)
cryptography==46.0.7
# via
@@ -141,7 +141,7 @@ flask-login==0.6.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
flask-migrate==3.1.0
flask-migrate==4.1.0
# via apache-superset (pyproject.toml)
flask-session==0.8.0
# via apache-superset (pyproject.toml)
@@ -384,7 +384,7 @@ setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.4
# via apache-superset (pyproject.toml)
simplejson==3.20.1
simplejson==4.1.1
# via apache-superset (pyproject.toml)
six==1.17.0
# via
+3 -3
View File
@@ -174,7 +174,7 @@ cron-descriptor==1.4.5
# via
# -c requirements/base-constraint.txt
# apache-superset
croniter==6.0.0
croniter==6.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -293,7 +293,7 @@ flask-login==0.6.3
# -c requirements/base-constraint.txt
# apache-superset
# flask-appbuilder
flask-migrate==3.1.0
flask-migrate==4.1.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -939,7 +939,7 @@ shillelagh==1.4.4
# via
# -c requirements/base-constraint.txt
# apache-superset
simplejson==3.20.1
simplejson==4.1.1
# via
# -c requirements/base-constraint.txt
# apache-superset
+140 -148
View File
@@ -27,19 +27,6 @@
"webpack-cli": "^5.1.4"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/cli": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.25.6.tgz",
@@ -71,12 +58,12 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -85,32 +72,30 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.25.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz",
"integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
"version": "7.29.6",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
"@babel/generator": "^7.25.0",
"@babel/helper-compilation-targets": "^7.25.2",
"@babel/helper-module-transforms": "^7.25.2",
"@babel/helpers": "^7.25.0",
"@babel/parser": "^7.25.0",
"@babel/template": "^7.25.0",
"@babel/traverse": "^7.25.2",
"@babel/types": "^7.25.2",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.6",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.29.2",
"@babel/parser": "^7.29.3",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -126,13 +111,13 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -169,15 +154,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
"integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.25.2",
"@babel/helper-validator-option": "^7.24.8",
"browserslist": "^4.23.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
@@ -382,29 +366,28 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz",
"integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -425,26 +408,25 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
"integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.25.0",
"@babel/types": "^7.25.6"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1843,14 +1825,14 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1875,13 +1857,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -2649,6 +2631,16 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@@ -7983,16 +7975,6 @@
}
},
"dependencies": {
"@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"requires": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@babel/cli": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.25.6.tgz",
@@ -8011,38 +7993,38 @@
}
},
"@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}
},
"@babel/compat-data": {
"version": "7.25.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz",
"integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true
},
"@babel/core": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
"version": "7.29.6",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
"dev": true,
"requires": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
"@babel/generator": "^7.25.0",
"@babel/helper-compilation-targets": "^7.25.2",
"@babel/helper-module-transforms": "^7.25.2",
"@babel/helpers": "^7.25.0",
"@babel/parser": "^7.25.0",
"@babel/template": "^7.25.0",
"@babel/traverse": "^7.25.2",
"@babel/types": "^7.25.2",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.6",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.29.2",
"@babel/parser": "^7.29.3",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -8051,13 +8033,13 @@
}
},
"@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"requires": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -8083,14 +8065,14 @@
}
},
"@babel/helper-compilation-targets": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
"integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"requires": {
"@babel/compat-data": "^7.25.2",
"@babel/helper-validator-option": "^7.24.8",
"browserslist": "^4.23.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
}
@@ -8229,21 +8211,21 @@
}
},
"@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true
},
"@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true
},
"@babel/helper-validator-option": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz",
"integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true
},
"@babel/helper-wrap-function": {
@@ -8258,22 +8240,22 @@
}
},
"@babel/helpers": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
"integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"requires": {
"@babel/template": "^7.25.0",
"@babel/types": "^7.25.6"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
}
},
"@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"requires": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
}
},
"@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
@@ -9157,14 +9139,14 @@
}
},
"@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
}
},
"@babel/traverse": {
@@ -9183,13 +9165,13 @@
}
},
"@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"requires": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
}
},
"@bcoe/v8-coverage": {
@@ -9771,6 +9753,16 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"requires": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
-34
View File
@@ -1,34 +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.
#
**/*{.,-}min.js
**/*.sh
coverage/**
dist/*
src/assets/images/*
node_modules/*
node_modules*/*
vendor/*
docs/*
src/dashboard/deprecated/*
src/temp/*
**/node_modules
*.d.ts
coverage/
esm/
lib/
tmp/
storybook-static/
-537
View File
@@ -1,537 +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.
*/
// Register TypeScript require hook so ESLint can load .ts plugin files
require('tsx/cjs');
const packageConfig = require('./package.json');
const importCoreModules = [];
Object.entries(packageConfig.dependencies).forEach(([pkg]) => {
if (/@superset-ui/.test(pkg)) {
importCoreModules.push(pkg);
}
});
// ignore files in production mode
let ignorePatterns = [];
if (process.env.NODE_ENV === 'production') {
ignorePatterns = [
'*.test.{js,ts,jsx,tsx}',
'plugins/**/test/**/*',
'packages/**/test/**/*',
'packages/generator-superset/**/*',
];
}
const restrictedImportsRules = {
'no-design-icons': {
name: '@ant-design/icons',
message:
'Avoid importing icons directly from @ant-design/icons. Use the src/components/Icons component instead.',
},
'no-moment': {
name: 'moment',
message:
'Please use the dayjs library instead of moment.js. See https://day.js.org',
},
'no-lodash-memoize': {
name: 'lodash/memoize',
message: 'Lodash Memoize is unsafe! Please use memoize-one instead',
},
'no-testing-library-react': {
name: '@superset-ui/core/spec',
message: 'Please use spec/helpers/testing-library instead',
},
'no-testing-library-react-dom-utils': {
name: '@testing-library/react-dom-utils',
message: 'Please use spec/helpers/testing-library instead',
},
'no-antd': {
name: 'antd',
message: 'Please import Ant components from the index of src/components',
},
'no-superset-theme': {
name: '@superset-ui/core',
importNames: ['supersetTheme'],
message:
'Please use the theme directly from the ThemeProvider rather than importing supersetTheme.',
},
'no-query-string': {
name: 'query-string',
message: 'Please use the URLSearchParams API instead of query-string.',
},
'no-jest-mock-console': {
name: 'jest-mock-console',
message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")',
},
};
module.exports = {
extends: [
'eslint:recommended',
'plugin:import/recommended',
'plugin:react-prefer-function-component/recommended',
'plugin:storybook/recommended',
'prettier',
],
parser: '@babel/eslint-parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
requireConfigFile: false,
babelOptions: {
presets: ['@babel/preset-react', '@babel/preset-env'],
},
},
env: {
browser: true,
node: true,
es2020: true,
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
moduleDirectory: ['node_modules', '.'],
},
typescript: {
alwaysTryTypes: true,
project: [
'./tsconfig.json',
'./packages/superset-ui-core/tsconfig.json',
'./packages/superset-ui-chart-controls/',
'./plugins/*/tsconfig.json',
],
},
},
'import/core-modules': importCoreModules,
react: {
version: 'detect',
},
},
plugins: [
'import',
'lodash',
'theme-colors',
'icons',
'i18n-strings',
'react-prefer-function-component',
'react-you-might-not-need-an-effect',
'prettier',
],
rules: {
// === Essential Superset customizations ===
// Prettier integration
'prettier/prettier': 'error',
// Custom Superset rules
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': 'error',
'i18n-strings/no-eager-t-in-config': 'off', // enabled only for controlPanel files via overrides below
// Core ESLint overrides for Superset
'no-console': 'warn',
'no-unused-vars': 'off', // TypeScript handles this
camelcase: [
'error',
{
allow: ['^UNSAFE_', '__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'],
properties: 'never',
},
],
'prefer-destructuring': ['error', { object: true, array: false }],
'no-prototype-builtins': 0,
curly: 'off',
// Import plugin overrides
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
'import/no-cycle': 0,
'import/prefer-default-export': 0,
'import/no-named-as-default-member': 0,
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
'test/**',
'tests/**',
'spec/**',
'**/__tests__/**',
'**/__mocks__/**',
'*.test.{js,jsx,ts,tsx}',
'*.spec.{js,jsx,ts,tsx}',
'**/*.test.{js,jsx,ts,tsx}',
'**/*.spec.{js,jsx,ts,tsx}',
'**/jest.config.js',
'**/jest.setup.js',
'**/webpack.config.js',
'**/webpack.config.*.js',
'**/.eslintrc*.js',
],
optionalDependencies: false,
},
],
// React plugin overrides
'react-prefer-function-component/react-prefer-function-component': 1,
// React effect best practices
'react-you-might-not-need-an-effect/no-empty-effect': 'error',
'react-you-might-not-need-an-effect/no-pass-live-state-to-parent': 'error',
'react-you-might-not-need-an-effect/no-initialize-state': 'error',
// Lodash
'lodash/import-scope': [2, 'member'],
// React effect best practices
'react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change':
'error',
'react-you-might-not-need-an-effect/no-chain-state-updates': 'error',
'react-you-might-not-need-an-effect/no-event-handler': 'error',
'react-you-might-not-need-an-effect/no-derived-state': 'error',
// Storybook
'storybook/prefer-pascal-case': 'error',
// File progress
'file-progress/activate': 1,
// React effect rules
'react-you-might-not-need-an-effect/no-adjust-state-on-prop-change':
'error',
'react-you-might-not-need-an-effect/no-pass-data-to-parent': 'error',
// Restricted imports
'no-restricted-imports': [
'error',
{
paths: Object.values(restrictedImportsRules).filter(Boolean),
patterns: ['antd/*'],
},
],
// Temporarily disabled for migration
'no-unsafe-optional-chaining': 0,
'no-import-assign': 0,
'import/no-relative-packages': 0,
'no-promise-executor-return': 0,
'import/no-import-module-exports': 0,
// Restrict certain syntax patterns
'no-restricted-syntax': [
'error',
{
selector:
"ImportDeclaration[source.value='react'] :matches(ImportDefaultSpecifier, ImportNamespaceSpecifier)",
message:
'Default React import is not required due to automatic JSX runtime in React 16.4',
},
{
selector: 'ImportNamespaceSpecifier[parent.source.value!=/^(\\.|src)/]',
message: 'Wildcard imports are not allowed',
},
],
},
overrides: [
// Eager t()/tn() in `label`/`description` config props is captured at
// module-load time, before i18n initializes — labels stay in the fallback
// language even after the user switches. Surfaced as a warning (with
// autofix to `() => t(...)`) wherever this is a real foot-gun:
// controlPanel files. Many pre-existing call sites need conversion;
// run `eslint --fix` on a controlPanel file to sweep it. Promote to
// `'error'` once the codebase is clean.
{
files: ['**/controlPanel.{ts,tsx,js,jsx}'],
rules: {
'i18n-strings/no-eager-t-in-config': 'warn',
},
},
// Ban JavaScript files in src/ - all new code must be TypeScript
{
files: ['src/**/*.js', 'src/**/*.jsx'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Program',
message:
'JavaScript files are not allowed in src/. Please use TypeScript (.ts/.tsx) instead.',
},
],
},
},
// Ban JavaScript files in plugins/ - all plugin source code must be TypeScript
{
files: ['plugins/**/src/**/*.js', 'plugins/**/src/**/*.jsx'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Program',
message:
'JavaScript files are not allowed in plugins/. Please use TypeScript (.ts/.tsx) instead.',
},
],
},
},
// Ban JavaScript files in packages/ - with exceptions for config files and generators
{
files: ['packages/**/src/**/*.js', 'packages/**/src/**/*.jsx'],
excludedFiles: [
'packages/generator-superset/**/*', // Yeoman generator templates run via Node
'packages/**/__mocks__/**/*', // Test mocks
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Program',
message:
'JavaScript files are not allowed in packages/. Please use TypeScript (.ts/.tsx) instead.',
},
],
},
},
{
files: ['*.ts', '*.tsx'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
extends: ['plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['@typescript-eslint/eslint-plugin'],
rules: {
// TypeScript-specific rule overrides
'@typescript-eslint/ban-ts-ignore': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/ban-types': 0,
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'enum',
format: ['PascalCase'],
},
{
selector: 'enumMember',
format: ['PascalCase'],
},
],
'@typescript-eslint/no-empty-function': 0,
'@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/no-use-before-define': 'error',
'@typescript-eslint/no-non-null-assertion': 0,
'@typescript-eslint/explicit-function-return-type': 0,
'@typescript-eslint/explicit-module-boundary-types': 0,
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/prefer-optional-chain': 'error',
// Disable base rules that conflict with TS versions
'no-unused-vars': 'off',
'no-use-before-define': 'off',
'no-shadow': 'off',
// Import overrides for TypeScript
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
},
settings: {
'import/resolver': {
typescript: {},
},
},
},
{
files: ['packages/**'],
rules: {
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: true },
],
'no-restricted-imports': [
'error',
{
paths: [
restrictedImportsRules['no-moment'],
restrictedImportsRules['no-lodash-memoize'],
restrictedImportsRules['no-superset-theme'],
],
patterns: [],
},
],
},
},
{
files: ['plugins/**'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
restrictedImportsRules['no-moment'],
restrictedImportsRules['no-lodash-memoize'],
],
patterns: [],
},
],
},
},
{
files: ['src/components/**', 'src/theme/**'],
rules: {
'no-restricted-imports': [
'error',
{
paths: Object.values(restrictedImportsRules).filter(
r => r.name !== 'antd',
),
patterns: [],
},
],
},
},
{
files: [
'*.test.ts',
'*.test.tsx',
'*.test.js',
'*.test.jsx',
'*.stories.tsx',
'*.stories.jsx',
'fixtures.*',
'**/test/**/*',
'**/tests/**/*',
'spec/**/*',
'**/fixtures/**/*',
'**/__mocks__/**/*',
'**/spec/**/*',
],
excludedFiles: 'cypress-base/cypress/**/*',
plugins: ['jest-dom', 'no-only-tests', 'testing-library'],
extends: ['plugin:jest-dom/recommended', 'plugin:testing-library/react'],
rules: {
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: true },
],
'prefer-promise-reject-errors': 0,
'max-classes-per-file': 0,
// Temporary for migration
'testing-library/await-async-queries': 0,
'testing-library/await-async-utils': 0,
'testing-library/no-await-sync-events': 0,
'testing-library/no-render-in-lifecycle': 0,
'testing-library/no-unnecessary-act': 0,
'testing-library/no-wait-for-multiple-assertions': 0,
'testing-library/prefer-screen-queries': 0,
'testing-library/await-async-events': 0,
'testing-library/no-node-access': 0,
'testing-library/no-wait-for-side-effects': 0,
'testing-library/prefer-presence-queries': 0,
'testing-library/render-result-naming-convention': 0,
'testing-library/no-container': 0,
'testing-library/prefer-find-by': 0,
'testing-library/no-manual-cleanup': 0,
'no-restricted-syntax': [
'error',
{
selector:
"ImportDeclaration[source.value='react'] :matches(ImportDefaultSpecifier, ImportNamespaceSpecifier)",
message:
'Default React import is not required due to automatic JSX runtime in React 16.4',
},
],
'no-restricted-imports': 0,
},
},
{
files: [
'*.test.ts',
'*.test.tsx',
'*.test.js',
'*.test.jsx',
'*.stories.tsx',
'*.stories.jsx',
'fixtures.*',
'**/test/**/*',
'**/tests/**/*',
'spec/**/*',
'**/fixtures/**/*',
'**/__mocks__/**/*',
'**/spec/**/*',
'cypress-base/cypress/**/*',
'Stories.tsx',
'packages/superset-ui-core/src/theme/index.tsx',
],
rules: {
'theme-colors/no-literal-colors': 0,
'icons/no-fa-icons-usage': 0,
'i18n-strings/no-template-vars': 0,
'no-restricted-imports': 0,
},
},
{
files: [
'packages/**/*.stories.*',
'packages/**/*.overview.*',
'packages/**/fixtures.*',
],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['playwright/**/*.ts', 'playwright/**/*.js'],
rules: {
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: true },
],
},
},
],
ignorePatterns,
};
-124
View File
@@ -1,124 +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.
*/
// Register TypeScript require hook so ESLint can load .ts plugin files
require('tsx/cjs');
/**
* MINIMAL ESLint config - ONLY for rules OXC doesn't support
* This config is designed to be run alongside OXC linter
*
* Only covers:
* - Custom Superset plugins (theme-colors, icons, i18n)
* - Prettier formatting
* - File progress indicator
*/
module.exports = {
root: true,
// Don't report on eslint-disable comments for rules we don't have
reportUnusedDisableDirectives: false,
// Simple parser - no TypeScript needed since OXC handles that
parser: '@babel/eslint-parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
requireConfigFile: false,
babelOptions: {
presets: ['@babel/preset-react', '@babel/preset-env'],
},
},
env: {
browser: true,
node: true,
es2020: true,
},
plugins: [
// ONLY custom Superset plugins that OXC doesn't support
'theme-colors',
'icons',
'i18n-strings',
'file-progress',
'prettier',
],
rules: {
// === ONLY rules that OXC cannot handle ===
// Prettier integration (formatting)
'prettier/prettier': 'error',
// Custom Superset plugins
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': 'error',
'file-progress/activate': 1,
// Explicitly turn off all other rules to avoid conflicts
// when the config gets merged with other configs
'import/no-unresolved': 'off',
'import/extensions': 'off',
'@typescript-eslint/naming-convention': 'off',
},
overrides: [
{
// Disable custom rules in test/story files
files: [
'**/*.test.*',
'**/*.spec.*',
'**/*.stories.*',
'**/test/**',
'**/tests/**',
'**/spec/**',
'**/__tests__/**',
'**/__mocks__/**',
'cypress-base/**',
'packages/superset-ui-core/src/theme/index.tsx',
],
rules: {
'theme-colors/no-literal-colors': 0,
'icons/no-fa-icons-usage': 0,
'i18n-strings/no-template-vars': 0,
'file-progress/activate': 0,
},
},
],
// Only check src/ files where theme/icon rules matter
ignorePatterns: [
'node_modules',
'dist',
'build',
'.next',
'coverage',
'*.min.js',
'vendor',
// Skip packages/plugins since they have different theming rules
'packages/**',
'plugins/**',
// Skip generated/external files
'*.generated.*',
'*.config.js',
'webpack.*',
// Temporary analysis files
'*.js', // Skip all standalone JS files in root
'*.json',
],
};
+18 -21
View File
@@ -4708,16 +4708,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -5029,10 +5028,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -10228,7 +10226,7 @@
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "4.1.1",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
},
"dependencies": {
@@ -10238,8 +10236,7 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"requires": {
"argparse": "^2.0.1"
@@ -12345,15 +12342,15 @@
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw=="
},
"form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
}
},
"fromentries": {
@@ -12574,9 +12571,9 @@
}
},
"hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"requires": {
"function-bind": "^1.1.2"
}
@@ -31,12 +31,13 @@ const plugin: { rules: Record<string, Rule.RuleModule> } = require('.');
// Tests
//------------------------------------------------------------------------------
const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } });
const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 6 } });
const rule: Rule.RuleModule = plugin.rules['no-template-vars'];
const errors: Array<{ type: string }> = [
const errors: Array<{ message: string }> = [
{
type: 'CallExpression',
message:
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it can't handle strings that include variables",
},
];
@@ -31,7 +31,10 @@ const plugin: { rules: Record<string, Rule.RuleModule> } = require('.');
// Tests
//------------------------------------------------------------------------------
const ruleTester = new RuleTester({
parserOptions: { ecmaVersion: 6, ecmaFeatures: { jsx: true } },
languageOptions: {
ecmaVersion: 6,
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const rule: Rule.RuleModule = plugin.rules['no-fa-icons-usage'];
+137
View File
@@ -0,0 +1,137 @@
/**
* 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.
*/
/**
* MINIMAL ESLint flat config - ONLY for rules OXC doesn't support.
*
* This config is run alongside the OXC (oxlint) linter, which handles the
* bulk of linting. ESLint here only covers the custom Superset plugins and
* Prettier formatting that oxlint cannot express. It is consumed by
* `scripts/oxlint-metrics-uploader.js` (`npm run lint-stats`).
*
* Migrated from the legacy `.eslintrc.minimal.js` (eslintrc) format to flat
* config for ESLint v9+/v10, where eslintrc is no longer supported.
*
* Only covers:
* - Custom Superset plugins (theme-colors, icons, i18n-strings)
* - Prettier formatting
*/
// Register the TypeScript require hook so ESLint can load the .ts plugin files
// from eslint-rules/*.
require('tsx/cjs');
const tsParser = require('@typescript-eslint/parser');
const prettierPlugin = require('eslint-plugin-prettier');
const themeColorsPlugin = require('eslint-plugin-theme-colors');
const iconsPlugin = require('eslint-plugin-icons');
const i18nStringsPlugin = require('eslint-plugin-i18n-strings');
module.exports = [
// Files this config applies to. Flat config has no `--ext`; globs live here.
// Only check src/ files where the theme/icon/i18n rules matter.
{
ignores: [
'node_modules/**',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'**/*.min.js',
'vendor/**',
// Skip packages/plugins since they have different theming rules
'packages/**',
'plugins/**',
// Skip generated/external/config files
'**/*.generated.*',
'**/*.config.js',
'**/webpack.*',
'*.json',
],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
// The @typescript-eslint parser handles both TS/TSX and plain JS/JSX and
// is compatible with ESLint v10's scope manager. (The legacy
// @babel/eslint-parser does not support ESLint v10.) The custom rules
// here are pure AST visitors and do not require type information, so no
// `project` is configured — this keeps parsing fast.
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
// Don't report on eslint-disable comments for rules we don't have.
linterOptions: {
reportUnusedDisableDirectives: false,
},
plugins: {
prettier: prettierPlugin,
'theme-colors': themeColorsPlugin,
icons: iconsPlugin,
'i18n-strings': i18nStringsPlugin,
},
rules: {
// Prettier integration (formatting)
'prettier/prettier': 'error',
// Custom Superset plugins
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': 'error',
// Enabled only for controlPanel files via the override below.
'i18n-strings/no-eager-t-in-config': 'off',
},
},
{
// Eager t()/tn() in `label`/`description` config props is captured at
// module-load time, before i18n initializes — labels stay in the fallback
// language even after the user switches. Surfaced as a warning (with
// autofix to `() => t(...)`) wherever this is a real foot-gun:
// controlPanel files. Promote to `'error'` once the codebase is clean.
files: ['**/controlPanel.{ts,tsx,js,jsx}'],
rules: {
'i18n-strings/no-eager-t-in-config': 'warn',
},
},
{
// Disable custom rules in test/story files
files: [
'**/*.test.*',
'**/*.spec.*',
'**/*.stories.*',
'**/test/**',
'**/tests/**',
'**/spec/**',
'**/__tests__/**',
'**/__mocks__/**',
'cypress-base/**',
],
rules: {
'theme-colors/no-literal-colors': 'off',
'icons/no-fa-icons-usage': 'off',
'i18n-strings/no-template-vars': 'off',
},
},
];
+7 -5
View File
@@ -287,13 +287,15 @@
"ignorePatterns": [
"packages/generator-superset/**/*",
"cypress-base/**",
"node_modules/**",
"**/node_modules/**",
"build/**",
"dist/**",
"lib/**",
"esm/**",
"*.min.js",
"**/dist/**",
"**/lib/**",
"**/esm/**",
"**/*.min.js",
"**/*.d.ts",
"coverage/**",
"storybook-static/**",
".git/**",
"**/*.config.js",
"**/*.config.ts"
+662 -629
View File
File diff suppressed because it is too large Load Diff
+26 -17
View File
@@ -178,14 +178,14 @@
"echarts": "^5.6.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.4.1",
"fuse.js": "^7.4.2",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.6.2",
"google-auth-library": "^10.7.0",
"immer": "^11.1.8",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -204,7 +204,7 @@
"query-string": "9.4.0",
"re-resizable": "^6.11.2",
"react": "^18.2.0",
"react-arborist": "^3.8.0",
"react-arborist": "^3.10.1",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
@@ -261,7 +261,7 @@
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.13",
"@formatjs/intl-durationformat": "^0.10.14",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.60.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
@@ -280,11 +280,11 @@
"@types/content-disposition": "^0.5.9",
"@types/dom-to-image": "^2.6.7",
"@types/jest": "^30.0.0",
"@types/jquery": "^4.0.0",
"@types/jquery": "^4.0.1",
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.9.1",
"@types/node": "^25.9.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
@@ -297,22 +297,22 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.59.4",
"@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/parser": "^8.61.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.33",
"baseline-browser-mapping": "^2.10.34",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"css-minimizer-webpack-plugin": "^8.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^7.2.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-cypress": "^3.6.0",
@@ -320,11 +320,11 @@
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest-dom": "^5.5.0",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.0",
"eslint-plugin-storybook": "10.4.2",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -344,7 +344,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.68.0",
"oxlint": "^1.69.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
@@ -368,9 +368,9 @@
"wait-on": "^9.0.10",
"webpack": "^5.107.2",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4",
"webpack-manifest-plugin": "^5.0.1",
"webpack-manifest-plugin": "^6.0.1",
"webpack-sources": "^3.5.0",
"webpack-visualizer-plugin2": "^2.0.0"
},
@@ -414,7 +414,16 @@
"@jest/types": "^30.4.0",
"jest-util": "^30.4.0",
"jest-circus": "^30.4.0",
"jest-environment-node": "^30.4.0"
"jest-environment-node": "^30.4.0",
"@babel/eslint-parser": {
"eslint": "$eslint"
},
"eslint-plugin-import": {
"eslint": "$eslint"
},
"eslint-plugin-jest-dom": {
"eslint": "$eslint"
}
},
"readme": "ERROR: No README data found!",
"scarfSettings": {
@@ -18,6 +18,14 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": {
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"
@@ -0,0 +1,184 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type (not a view): an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* () => <AcmeTrigger />,
* () => <AcmePanel />,
* );
* ```
*/
import { ReactElement } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat, for display in contribution manifests. */
description?: string;
}
export type DisplayMode = 'floating' | 'panel';
/**
* Registers a chat provider. The host applies singleton resolution — only one
* chat is active at a time: the most recently registered chat wins, and
* disposing it restores the previously registered one. Re-registering an id
* replaces that registration in place.
*
* When a registration with a different id takes over the active slot (or the
* active chat is disposed), the host closes the panel first, firing
* {@link onDidClose}; an in-place same-id replacement keeps the open state.
*
* Disposing the returned Disposable unregisters the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger A function returning the collapsed bubble element. Owned by
* the extension — dynamic state such as unread counts and badges lives here.
* Hidden by the host when in panel mode.
* @param panel A function returning the chat panel element. Mounted by the
* host as a floating overlay in 'floating' mode, or docked at the side of
* the viewport in 'panel' mode (the reference host docks a fixed-width
* overlay at the right edge; hosts may integrate a true layout slot
* instead). Same component in both modes.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* () => <AcmeTrigger />,
* () => <AcmePanel />,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: () => ReactElement,
panel: () => ReactElement,
): Disposable;
/**
* Returns the active chat descriptor.
*
* @returns A copy of the active Chat descriptor, or undefined if none is
* registered. Mutating the returned object has no effect on the registry.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*
* @returns True if the chat panel is open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens. Also fired by the host's own
* controls, not only by an extension's open() call.
*/
export declare const onDidOpen: Event<void>;
/**
* Event fired when the chat panel closes. Also fired when the host closes the
* panel itself, e.g. because the active chat was disposed or displaced by a
* different chat.
*/
export declare const onDidClose: Event<void>;
/**
* Returns the current display mode.
*
* @returns The current DisplayMode.
*/
export declare function getDisplayMode(): DisplayMode;
/**
* Sets the display mode.
*
* The mode is host-global and applies to whichever chat is active, regardless
* of which extension calls it. Hosts may also change the mode through their
* own controls — use onDidChangeDisplayMode to observe all changes rather than
* assuming the last setDisplayMode() call won.
*
* @param displayMode The display mode to switch to.
*/
export declare function setDisplayMode(displayMode: DisplayMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setDisplayMode() or by host-provided controls.
*/
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
/**
* Event fired when the panel is resized in panel mode.
*
* The host owns the resizer handle and drag interaction; a host without a
* resizer never fires this event. (The reference host mounts the panel at a
* fixed width and does not provide a resizer, so subscribers receive no
* events there.) Listen to this event to adapt internal layout to the
* available width; do not rely on it firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.
@@ -223,8 +223,6 @@ export interface Extension {
dependencies: string[];
/** Human-readable description of the extension */
description: string;
/** List of other extensions that this extension depends on */
extensionDependencies: string[];
/** Unique identifier for the extension */
id: string;
/** Human-readable name of the extension */
@@ -23,9 +23,10 @@
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema.
* menus, editors, chat) and re-exported here for the manifest schema.
*/
import { Chat } from '../chat';
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
@@ -71,7 +72,8 @@ export interface MenuContributions {
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
* Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
@@ -82,4 +84,10 @@ export interface Contributions {
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
/**
* The chat contributed by the extension — at most one per extension, since
* the host applies singleton resolution and renders exactly one active
* chat at a time.
*/
chat?: Chat;
}
@@ -18,10 +18,12 @@
*/
export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab';
export * as views from './views';
export * as contributions from './contributions';
@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Navigation namespace for Superset extensions.
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — surface-specific namespaces that
* resolve entity payloads are introduced in later phases.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
* the welcome surface and the fallback for any route not explicitly enumerated.
*/
export type Page =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home';
/**
* Returns the current page surface.
*
* @example
* ```typescript
* const page = navigation.getPage();
* if (page === 'dashboard') {
* // react to being on a dashboard surface
* }
* ```
*/
export declare function getPage(): Page;
/**
* Event fired whenever the user navigates to a different surface.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(page => {
* if (page === 'dashboard') {
* // react to navigating onto a dashboard surface
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<Page>;
@@ -43,7 +43,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
@@ -75,9 +75,9 @@
"@types/d3-scale": "^2.1.1",
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.0",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^25.9.1",
"@types/node": "^25.9.2",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -70,11 +70,46 @@ test('a change event that arrives before isEditing flips is not dropped', () =>
});
test('prop changes mid-edit do not clobber unsaved typing', async () => {
const { rerender } = render(<Harness initialTitle="Foo" />);
// Rerender DynamicEditableTitle directly with a changed title prop so the
// sync effect actually runs. Going through Harness would not exercise the
// bug because Harness owns its own state and only reads initialTitle once.
const onSave = jest.fn();
const props = {
placeholder: 'placeholder',
canEdit: true,
label: 'Title',
onSave,
};
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
await userEvent.type(input, 'X', { delay: 1 });
expect(input.value).toBe('FooX');
rerender(<Harness initialTitle="Foo" />);
rerender(<DynamicEditableTitle {...props} title="Bar" />);
expect(input.value).toBe('FooX');
// Locks in commit semantics: blur after a real edit must persist the
// user's typed value, even when a competing parent-driven title arrived
// mid-edit.
fireEvent.blur(input);
expect(onSave).toHaveBeenCalledWith('FooX');
});
test('passive focus then parent-driven title change then blur does not revert', () => {
// Phantom-revert scenario: user clicks the input but does not type, the
// parent autosaves a new title from elsewhere, then the user blurs. The
// component must NOT call onSave with the stale local value, otherwise it
// would silently overwrite the parent's update.
const onSave = jest.fn();
const props = {
placeholder: 'placeholder',
canEdit: true,
label: 'Title',
onSave,
};
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
rerender(<DynamicEditableTitle {...props} title="Bar" />);
fireEvent.blur(input);
expect(onSave).not.toHaveBeenCalled();
});
@@ -81,12 +81,25 @@ export const DynamicEditableTitle = memo(
const sizerRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<InputRef>(null);
// Tracks whether the user has actually typed since entering edit mode.
// Gates onSave so that passive focus (click without typing) followed by a
// parent-driven title change and blur does not silently revert the
// parent's update with our stale currentTitle.
const dirtyRef = useRef(false);
const { width: containerWidth, ref: containerRef } = useResizeDetector({
refreshMode: 'debounce',
});
useEffect(() => {
setCurrentTitle(title);
// Don't overwrite in-flight user input when the parent re-renders with a
// new title prop mid-edit. handleBlur already syncs currentTitle on commit;
// re-running this effect when isEditing flips would resync to a stale
// title prop, so isEditing is intentionally read via closure rather than
// listed as a dep.
if (!isEditing) {
setCurrentTitle(title);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title]);
useEffect(() => {
if (isEditing) {
@@ -138,10 +151,19 @@ export const DynamicEditableTitle = memo(
return;
}
const formattedTitle = currentTitle.trim();
setCurrentTitle(formattedTitle);
if (title !== formattedTitle) {
// Only commit when the user actually typed. Passive focus must not
// overwrite a parent-driven title change that landed mid-edit.
if (dirtyRef.current && title !== formattedTitle) {
setCurrentTitle(formattedTitle);
onSave(formattedTitle);
} else if (!dirtyRef.current) {
// Drop any stale local state and resync to the latest title prop so a
// subsequent edit starts from the current parent value.
setCurrentTitle(title);
} else {
setCurrentTitle(formattedTitle);
}
dirtyRef.current = false;
setIsEditing(false);
}, [canEdit, currentTitle, onSave, title]);
@@ -158,6 +180,7 @@ export const DynamicEditableTitle = memo(
if (!isEditing) {
setIsEditing(true);
}
dirtyRef.current = true;
setCurrentTitle(ev.target.value);
},
[canEdit, isEditing],
@@ -29,12 +29,33 @@ export class ExplorePage {
private static readonly SELECTORS = {
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
CHART_CONTAINER: '[data-test="chart-container"]',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Navigates to the Explore page for a given chart and waits for it to load.
*
* @param chartId - ID of the chart (slice) to open
* @param options - Optional wait options
*/
async goto(chartId: number, options?: { timeout?: number }): Promise<void> {
await this.page.goto(`explore/?slice_id=${chartId}`);
await this.waitForPageLoad(options);
}
/**
* Gets the chart container locator (where the rendered viz appears).
*
* @returns Locator for the chart container
*/
getChartContainer(): Locator {
return this.page.locator(ExplorePage.SELECTORS.CHART_CONTAINER);
}
/**
* Waits for the Explore page to load.
* Validates URL contains /explore/ and datasource control is visible.
@@ -0,0 +1,104 @@
/**
* 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.
*/
/**
* Regression for #32960: the `formatDate` Handlebars helper (provided by
* just-handlebars-helpers) stopped working after 4.1.2, rendering
* "i is not a function" (minified) / "moment is not a function" (dev) instead
* of the formatted date. The library helper resolves `moment` lazily via
* `global.moment` / `require('moment/min/moment-with-locales')`, which the
* bundled HandlebarsViewer no longer satisfies (it switched to dayjs).
*
* The fix registers a dayjs-backed `formatDate` override in HandlebarsViewer
* (superset-frontend/plugins/plugin-chart-handlebars). This spec guards it: it
* creates a Handlebars chart whose template uses `{{formatDate 'DD.MM.YYYY' ds}}`
* and asserts the chart renders a real formatted date rather than the helper
* error. Because the failure was a bundling/minification artifact (moment
* resolves fine under Jest's Node `require`), an E2E test is required to cover it.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
import { ExplorePage } from '../../pages/ExplorePage';
import { TIMEOUT } from '../../utils/constants';
const DATASET_NAME = 'birth_names';
testWithAssets(
'Handlebars formatDate helper renders a formatted date (#32960)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const datasetId = dataset.id;
const params = {
datasource: `${datasetId}__table`,
viz_type: 'handlebars',
query_mode: 'aggregate',
groupby: ['ds'],
metrics: ['count'],
adhoc_filters: [],
row_limit: 5,
// Note: HTML_SANITIZATION (on by default) strips non-allowlisted
// attributes such as `class`, so the rendered markup is plain
// <ul>/<li> elements. The assertions below target `li` directly.
handlebarsTemplate:
'<ul>{{#each data}}' +
"<li>{{formatDate 'DD.MM.YYYY' ds}}</li>" +
'{{/each}}</ul>',
styleTemplate: '',
};
const chartResp = await apiPostChart(page, {
slice_name: `handlebars_format_date_${Date.now()}`,
viz_type: 'handlebars',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(params),
});
expect(chartResp.ok()).toBe(true);
// The chart API may return either a top-level `{ id }` or a wrapped
// `{ result: { id } }` shape; handle both and fail explicitly otherwise.
const chartBody = await chartResp.json();
const chartId: number = chartBody.result?.id ?? chartBody.id;
expect(chartId, 'chart creation should return an id').toBeTruthy();
testAssets.trackChart(chartId);
const explorePage = new ExplorePage(page);
await explorePage.goto(chartId);
const panel = explorePage.getChartContainer();
await panel.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD });
// The helper error surfaces as a "... is not a function" message rendered
// in place of the chart content.
await expect(panel).not.toContainText('is not a function', {
timeout: TIMEOUT.API_RESPONSE,
});
// At least one list item should contain a DD.MM.YYYY formatted date.
await expect(panel.locator('li').first()).toHaveText(/\d{2}\.\d{2}\.\d{4}/, {
timeout: TIMEOUT.API_RESPONSE,
});
},
);
@@ -0,0 +1,309 @@
/**
* 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.
*/
/**
* Regression for #33406: in a Pivot Table (v2) with nested rows, collapsing a
* row group with the [-] toggle should stay collapsed after the collapsed rows
* scroll out of the viewport and back. The bug reproduces specifically when the
* dashboard is embedded via an iframe — the collapse/expand state lives in the
* pivot renderer's local React state (`collapsedRows` initialised to `{}`), so
* anything that remounts the chart resets it and the rows re-expand.
*
* This spec runs on the embedded harness (the only place the bug is reported to
* reproduce). It collapses a top-level row, scrolls the embedded dashboard so
* the pivot leaves and re-enters the viewport, and asserts the row is still
* collapsed.
*
* CI green => collapse state survives the scroll round-trip; merging closes
* #33406 and guards against regressions.
* CI red => the rows re-expanded; the bug is live and the fix belongs in
* plugin-chart-pivot-table (lift collapse state out of transient
* component state, e.g. persist `collapsedRows`/`collapsedCols`).
*
* NOTE: the embedded suite only runs when the embedded SDK bundle is built and
* INCLUDE_EMBEDDED=true (CI sets both). It is skipped otherwise.
*/
import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
import { AddressInfo, Socket } from 'net';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import {
apiEnableEmbedding,
getAccessToken,
getGuestToken,
} from '../../helpers/api/embedded';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard, apiDeleteDashboard } from '../../helpers/api/dashboard';
import { apiDeleteChart } from '../../helpers/api/chart';
import { EmbeddedPage } from '../../pages/EmbeddedPage';
import { EMBEDDED } from '../../utils/constants';
const SUPERSET_DOMAIN = (() => {
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
return url.replace(/\/+$/, '');
})();
const SUPERSET_BASE_URL = SUPERSET_DOMAIN.endsWith('/')
? SUPERSET_DOMAIN
: `${SUPERSET_DOMAIN}/`;
const SDK_BUNDLE_PATH = join(
__dirname,
'../../../../superset-embedded-sdk/bundle/index.js',
);
const EMBED_APP_DIR = join(__dirname, '../../embedded-app');
const INDEX_HTML_PATH = join(EMBED_APP_DIR, 'index.html');
const DATASET_NAME = 'birth_names';
interface EmbedAppServer {
server: Server;
url: string;
close: () => Promise<void>;
}
async function startEmbedAppServer(): Promise<EmbedAppServer> {
const sockets = new Set<Socket>();
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const urlPath = req.url?.split('?')[0] || '/';
if (urlPath === '/sdk/index.js') {
if (!existsSync(SDK_BUNDLE_PATH)) {
res.writeHead(404);
res.end(
'SDK bundle not found. Run: cd superset-embedded-sdk && npm ci && npm run build',
);
return;
}
res.writeHead(200, { 'Content-Type': 'text/javascript' });
res.end(readFileSync(SDK_BUNDLE_PATH));
return;
}
if (urlPath === '/' || urlPath === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(readFileSync(INDEX_HTML_PATH));
return;
}
res.writeHead(404);
res.end('Not found');
});
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', reject);
resolve();
});
});
const address = server.address() as AddressInfo;
return {
server,
url: `http://127.0.0.1:${address.port}`,
close: () =>
new Promise<void>(resolve => {
for (const socket of sockets) socket.destroy();
sockets.clear();
server.close(() => resolve());
}),
};
}
function createAdminContext(browser: Browser): Promise<BrowserContext> {
return browser.newContext({
storageState: 'playwright/.auth/user.json',
baseURL: SUPERSET_BASE_URL,
});
}
async function findDatasetIdByName(page: Page, name: string): Promise<number> {
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
const body = await resp.json();
if (!body.result?.length) {
throw new Error(`Dataset ${name} not found`);
}
return body.result[0].id;
}
test.describe('Embedded Pivot Table collapse state (#33406)', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(90000);
let appServer: EmbedAppServer;
let accessToken: string;
let embedUuid: string;
let dashboardId: number;
let chartId: number;
test.beforeAll(async ({ browser }) => {
test.skip(
!existsSync(SDK_BUNDLE_PATH),
'Embedded SDK bundle not found. Build it with: cd superset-embedded-sdk && npm ci && npm run build',
);
appServer = await startEmbedAppServer();
const context = await createAdminContext(browser);
const setupPage = await context.newPage();
try {
const datasetId = await findDatasetIdByName(setupPage, DATASET_NAME);
const params = {
datasource: `${datasetId}__table`,
viz_type: 'pivot_table_v2',
groupbyRows: ['state', 'name'],
groupbyColumns: [],
metrics: ['count'],
metricsLayout: 'COLUMNS',
aggregateFunction: 'Count',
rowSubTotals: true,
rowTotals: true,
valueFormat: 'SMART_NUMBER',
row_limit: 1000,
order_desc: true,
};
const chartResp = await apiPost(setupPage, 'api/v1/chart/', {
slice_name: `pivot_collapse_repro_${Date.now()}`,
viz_type: 'pivot_table_v2',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(params),
});
chartId = (await chartResp.json()).id;
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: {
chartId,
width: 6,
height: 80,
sliceName: 'pivot_collapse_repro',
},
},
};
const dashResp = await apiPostDashboard(setupPage, {
dashboard_title: `pivot_collapse_repro_${Date.now()}`,
published: true,
position_json: JSON.stringify(positionJson),
});
const dashBody = await dashResp.json();
dashboardId = dashBody.id;
await apiPut(setupPage, `api/v1/chart/${chartId}`, {
dashboards: [dashboardId],
});
const embedded = await apiEnableEmbedding(setupPage, dashboardId);
embedUuid = embedded.uuid;
accessToken = await getAccessToken(setupPage);
} finally {
await context.close();
}
});
test.afterAll(async ({ browser }) => {
const context = await createAdminContext(browser);
try {
const cleanupPage = await context.newPage();
if (dashboardId !== undefined) {
await apiDeleteDashboard(cleanupPage, dashboardId, {
failOnStatusCode: false,
});
}
if (chartId !== undefined) {
await apiDeleteChart(cleanupPage, chartId, { failOnStatusCode: false });
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[pivot-collapse teardown] cleanup failed:', err);
} finally {
await context.close();
}
if (appServer) await appServer.close();
});
test('collapsed rows stay collapsed after a scroll round-trip', async ({
page,
}) => {
const embeddedPage = new EmbeddedPage(page);
await embeddedPage.exposeTokenFetcher(async () =>
getGuestToken(page, dashboardId, { accessToken }),
);
await embeddedPage.goto({
appUrl: appServer.url,
uuid: embedUuid,
supersetDomain: SUPERSET_DOMAIN,
});
await embeddedPage.waitForIframe();
await embeddedPage.waitForDashboardContent();
await embeddedPage.waitForChartRendered();
const rowLabels = embeddedPage.iframe.locator('.pvtRowLabel');
await expect
.poll(() => rowLabels.count(), { timeout: EMBEDDED.CHART_RENDER })
.toBeGreaterThan(1);
const expandedCount = await rowLabels.count();
// Collapse the first top-level row group via its [-] toggle. Scope to
// `.pvtTable` so we never match a stray `toggle` class elsewhere in the DOM.
await embeddedPage.iframe.locator('.pvtTable .toggle').first().click();
await expect
.poll(() => embeddedPage.iframe.locator('.pvtRowLabel').count(), {
timeout: EMBEDDED.CHART_RENDER,
})
.toBeLessThan(expandedCount);
const collapsedCount = await embeddedPage.iframe
.locator('.pvtRowLabel')
.count();
// Scroll the embedded dashboard so the pivot leaves the viewport, then back.
await embeddedPage.iframe.locator('body').evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
await page.waitForTimeout(800);
await embeddedPage.iframe.locator('body').evaluate(() => {
window.scrollTo(0, 0);
});
await page.waitForTimeout(1200);
// The collapsed group must remain collapsed (row-label count unchanged).
await expect(embeddedPage.iframe.locator('.pvtRowLabel')).toHaveCount(
collapsedCount,
);
});
});
@@ -34,7 +34,7 @@
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},
@@ -116,3 +116,22 @@ Handlebars.registerHelper('parseJson', (jsonString: string) => {
Helpers.registerHelpers(Handlebars);
HandlebarsGroupBy.register(Handlebars);
// `just-handlebars-helpers` registers a `formatDate` helper that lazily
// resolves `moment` via `global.moment` / `require('moment/min/moment-with-locales')`.
// The bundled viewer switched to dayjs and never satisfies that lookup, so the
// original helper throws "... is not a function" (see #32960). Re-register a
// dayjs-backed `formatDate` with the same `{{formatDate formatString date [locale]}}`
// signature so existing templates keep rendering.
Handlebars.registerHelper('formatDate', (formatString, date, localeString) => {
const format = typeof formatString === 'string' ? formatString : '';
const instance = dayjs(date ?? new Date());
// Handlebars always passes its options object as the final argument, so a
// locale is only present when the caller supplied an explicit string.
// Note: `extendedDayjs` only loads the `en` locale, so passing a non-English
// locale here quietly falls back to English unless that locale bundle has
// been imported elsewhere; dayjs's instance `.locale()` is a no-op otherwise.
return typeof localeString === 'string'
? instance.locale(localeString).format(format)
: instance.format(format);
});
@@ -0,0 +1,70 @@
/**
* 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 Handlebars from 'handlebars';
// Importing the viewer registers the dayjs-backed `formatDate` override (#32960).
// The end-to-end behavior (the bundling/minification regression) is covered by a
// Playwright spec; these unit tests guard the helper's edge cases, which run fine
// under Jest's Node environment without a browser.
import '../../src/components/Handlebars/HandlebarsViewer';
// Handlebars passes its options object as the trailing argument, so callers that
// omit the optional locale still get a non-string final arg. Mimic that here.
const options = {} as unknown as string;
const formatDate = (
format: string,
date: unknown,
locale: string = options,
): string =>
(Handlebars.helpers.formatDate as (...args: unknown[]) => string)(
format,
date,
locale,
);
test('formats a valid date string with the supplied format', () => {
expect(formatDate('DD.MM.YYYY', '2024-06-14')).toBe('14.06.2024');
});
test('renders "Invalid date" for an unparseable date string', () => {
expect(formatDate('DD.MM.YYYY', 'not-a-date')).toBe('Invalid date');
});
test('coerces a non-string format to dayjs default output without throwing', () => {
// The helper guards against a non-string format by passing '' to dayjs,
// which renders its default ISO 8601 representation rather than throwing.
expect(() =>
formatDate(undefined as unknown as string, '2024-06-14'),
).not.toThrow();
expect(formatDate(undefined as unknown as string, '2024-06-14')).toContain(
'2024-06-14',
);
});
test('preserves the epoch-0 timestamp instead of falling back to now', () => {
// 1970-01-01 in UTC, which is 1969 or 1970 locally depending on tz offset;
// the point is it is NOT coerced to the current date.
expect(formatDate('YYYY', 0)).toMatch(/^(1969|1970)$/);
});
test('silently falls back to English for a locale that is not loaded', () => {
// extendedDayjs only loads the `en` locale, so a non-English locale no-ops.
expect(formatDate('MMMM', '2024-06-14', 'fr')).toBe('June');
});
@@ -21,6 +21,7 @@ import { css, styled } from '@apache-superset/core/theme';
export default styled.div`
${({ theme }) => css`
/* Base table styles */
padding: ${theme.sizeUnit * 5}px;
table {
width: 100%;
min-width: auto;
@@ -1371,7 +1371,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
)
) : undefined,
sortDescFirst: sortDesc,
sortType: getSortTypeByDataType(dataType),
// Metrics and percent metrics always have numeric values; use numeric sort
// even if the backend reports the column type as String.
sortType:
isMetric || isPercentMetric
? 'basic'
: getSortTypeByDataType(dataType),
};
},
[
@@ -1608,8 +1613,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
pageSize={pageSize}
serverPaginationData={serverPaginationData}
pageSizeOptions={pageSizeOptions}
width={widthFromState}
height={heightFromState}
width={Math.max(0, widthFromState - theme.sizeUnit * 10)}
height={Math.max(0, heightFromState - theme.sizeUnit * 10)}
serverPagination={serverPagination}
onServerPaginationChange={handleServerPaginationChange}
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}
@@ -2457,3 +2457,80 @@ describe('Drill-to-Detail Temporal Range Logic', () => {
expect(filter.val).toBeNull();
});
});
// Numeric values with String dataType (e.g. backend mis-reports type for computed columns)
// get 'alphanumeric' sort, which treats raw numbers as non-strings and produces unstable order.
// They should sort numerically regardless of display format.
test('sorts numeric-backed percentage column numerically when dataType is String', async () => {
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
order_desc: false,
metrics: ['pct'],
column_config: {
pct: { d3NumberFormat: '.1%' },
},
},
queriesData: [
{
...testData.raw.queriesData[0],
colnames: ['pct'],
coltypes: [GenericDataType.String],
data: [
{ pct: 0.4 },
{ pct: 0.056 },
{ pct: 0.506 },
{ pct: 0.066 },
{ pct: 0.41 },
],
},
],
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const header = screen.getByText('pct');
fireEvent.click(header);
const cells = document.querySelectorAll('tbody td');
const values = Array.from(cells).map(td => td.textContent);
expect(values).toEqual(['5.6%', '6.6%', '40.0%', '41.0%', '50.6%']);
});
test('sorts genuinely string columns alphanumerically', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
order_desc: false,
metrics: [],
columns: ['label'],
},
queriesData: [
{
...testData.raw.queriesData[0],
colnames: ['label'],
coltypes: [GenericDataType.String],
data: [{ label: 'banana' }, { label: 'apple' }, { label: 'cherry' }],
},
],
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const header = screen.getByText('label');
fireEvent.click(header);
const cells = document.querySelectorAll('tbody td');
const values = Array.from(cells).map(td => td.textContent);
expect(values).toEqual(['apple', 'banana', 'cherry']);
});
@@ -17,7 +17,11 @@
* under the License.
*/
import type { ReactElement } from 'react';
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
import type {
ControlPanelSectionConfig,
CustomControlItem,
} from '@superset-ui/chart-controls';
import { isCustomControlItem } from '@superset-ui/chart-controls';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render } from '@testing-library/react';
import { SqlaFormData } from '@superset-ui/core';
@@ -28,6 +32,7 @@ import DeckGLGeoJson, {
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
getPoints,
getLayer,
} from './Geojson';
import controlPanel from './controlPanel';
@@ -295,3 +300,158 @@ test('DeckGLGeoJson falls back to legacy map_style when provider-specific style
}),
);
});
const baseFormData: SqlaFormData = {
datasource: 'test_datasource',
viz_type: 'deck_geojson',
slice_id: 1,
fill_color_picker: { r: 0, g: 0, b: 255, a: 1 },
stroke_color_picker: { r: 0, g: 0, b: 0, a: 1 },
};
const baseLayerArgs = {
onContextMenu: jest.fn(),
filterState: undefined,
setDataMask: jest.fn(),
payload: { data: { type: 'FeatureCollection', features: [] } },
setTooltip: jest.fn(),
emitCrossFilters: false,
};
test('getLayer preserves rendering for existing charts without new point radius fields', () => {
// Simulate form data from an existing chart that only has point_radius_scale
const legacyFormData = {
...baseFormData,
point_radius_scale: 200,
// point_radius and point_radius_units intentionally absent
};
const layer = getLayer({ formData: legacyFormData, ...baseLayerArgs });
const { props } = layer;
// Should match deck.gl defaults, NOT the new control panel defaults
expect(props.getPointRadius).toBe(1); // deck.gl default, not 10
expect(props.pointRadiusUnits).toBe('meters'); // deck.gl default, not 'pixels'
expect(props.pointRadiusScale).toBe(200); // user's saved value preserved
});
test('getLayer uses control panel defaults for new charts', () => {
const newChartFormData = {
...baseFormData,
point_radius: 10,
point_radius_units: 'pixels',
point_radius_scale: 1,
};
const layer = getLayer({ formData: newChartFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(10);
expect(props.pointRadiusUnits).toBe('pixels');
expect(props.pointRadiusScale).toBe(1);
});
test('getLayer falls back to defaults when legacy fields are null', () => {
// The old point_radius_scale control had `default: null`, so legacy charts
// can have null persisted; it must fall back to 1, not coerce to 0.
const nullFormData = {
...baseFormData,
point_radius: null,
point_radius_scale: null,
};
const layer = getLayer({ formData: nullFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(1);
expect(props.pointRadiusScale).toBe(1);
});
test('getLayer preserves an explicit zero radius scale', () => {
const zeroFormData = {
...baseFormData,
point_radius_scale: 0,
};
const layer = getLayer({ formData: zeroFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.pointRadiusScale).toBe(0);
});
test('getLayer coerces free-form string radius values to numbers', () => {
// Free-form SelectControls can store user-typed values as strings
const stringFormData = {
...baseFormData,
point_radius: '3',
point_radius_scale: '0.25',
};
const layer = getLayer({ formData: stringFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(3);
expect(props.pointRadiusScale).toBe(0.25);
});
type ControlConfig = {
default?: unknown;
validators?: unknown[];
choices?: [unknown, unknown][];
renderTrigger?: boolean;
};
const controlItems = controlPanel.controlPanelSections
.filter(
(s: ControlPanelSectionConfig | null): s is ControlPanelSectionConfig =>
s !== null,
)
.flatMap((section: ControlPanelSectionConfig) => section.controlSetRows)
.flat();
const findControlConfig = (name: string): ControlConfig | undefined =>
(controlItems.filter(isCustomControlItem) as CustomControlItem[]).find(
(item: CustomControlItem) => item.name === name,
)?.config as ControlConfig | undefined;
test('controlPanel exposes a Point Radius control defaulting to 10', () => {
const config = findControlConfig('point_radius');
expect(config).toBeDefined();
expect(config?.default).toBe(10);
expect(config?.renderTrigger).toBe(true);
expect(config?.validators).toHaveLength(1);
expect(config?.choices).toEqual(
expect.arrayContaining([
[1, '1'],
[10, '10'],
[100, '100'],
]),
);
});
test('controlPanel Point Radius Scale defaults to 1 with fractional choices', () => {
const config = findControlConfig('point_radius_scale');
expect(config).toBeDefined();
expect(config?.default).toBe(1);
expect(config?.renderTrigger).toBe(true);
expect(config?.validators).toHaveLength(1);
expect(config?.choices).toEqual(
expect.arrayContaining([
[0.1, '0.1'],
[1, '1'],
[10, '10'],
]),
);
});
test('controlPanel Point Radius Units defaults to pixels', () => {
const config = findControlConfig('point_radius_units');
expect(config).toBeDefined();
expect(config?.default).toBe('pixels');
expect(config?.renderTrigger).toBe(true);
expect(config?.choices?.map(([value]) => value)).toEqual([
'pixels',
'meters',
'common',
]);
});
@@ -254,6 +254,15 @@ export const computeGeoJsonIconOptionsFromFormData = (
iconSizeUnits: fd.icon_size_unit,
});
// Free-form SelectControls can yield string values, and legacy charts may have
// null persisted for these fields, so coerce to a number (falling back to the
// provided default for null/undefined/NaN input, while preserving an explicit 0)
// before handing them to deck.gl's numeric layer props.
const toNumber = (value: unknown, fallback: number) => {
const num = Number(value ?? fallback);
return Number.isFinite(num) ? num : fallback;
};
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
formData,
onContextMenu,
@@ -328,7 +337,11 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
getFillColor(feature, filterState?.value),
getLineColor,
getLineWidth: fd.line_width || 1,
pointRadiusScale: fd.point_radius_scale,
// Use deck.gl defaults as fallbacks for backward compatibility with existing charts.
// New charts will get control panel defaults (point_radius=10, units='pixels', scale=1).
getPointRadius: toNumber(fd.point_radius, 1),
pointRadiusUnits: fd.point_radius_units ?? 'meters',
pointRadiusScale: toNumber(fd.point_radius_scale, 1),
lineWidthUnits: fd.line_width_unit,
pointType,
...labelOpts,
@@ -22,6 +22,8 @@ import {
legacyValidateInteger,
isFeatureEnabled,
FeatureFlag,
validateNumber,
validateInteger,
} from '@superset-ui/core';
import { formatSelectOptions } from '../../utilities/utils';
import {
@@ -352,15 +354,56 @@ const config: ControlPanelConfig = {
},
],
[
{
name: 'point_radius',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius'),
description: t(
'The radius of point features, in the units specified below. ' +
'The final rendered size is this value multiplied by Point Radius Scale.',
),
validators: [validateInteger],
default: 10,
choices: formatSelectOptions([1, 5, 10, 20, 50, 100]),
renderTrigger: true,
},
},
{
name: 'point_radius_scale',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius Scale'),
validators: [legacyValidateInteger],
default: null,
choices: formatSelectOptions([0, 100, 200, 300, 500]),
description: t(
'A multiplier applied to the point radius. ' +
'Use this to uniformly scale all points.',
),
validators: [validateNumber],
default: 1,
choices: formatSelectOptions([0.1, 0.5, 1, 2, 5, 10]),
renderTrigger: true,
},
},
],
[
{
name: 'point_radius_units',
config: {
type: 'SelectControl',
label: t('Point Radius Units'),
description: t(
'The unit for point radius. Use "pixels" for consistent ' +
'screen-space sizing regardless of zoom level.',
),
default: 'pixels',
choices: [
['pixels', t('Pixels')],
['meters', t('Meters')],
['common', t('Common (unit per pixel at zoom 0)')],
],
renderTrigger: true,
},
},
],
@@ -134,9 +134,11 @@ async function runOxlintAndProcess() {
console.log('Running minimal ESLint for custom rules...');
let eslintOutput = '[]';
try {
// Run ESLint and capture output directly
// Run ESLint and capture output directly.
// Flat config (eslint.config.minimal.js) is explicitly selected via
// --config; ESLint v9+/v10 no longer support eslintrc or --no-eslintrc.
eslintOutput = execSync(
'npx eslint --no-eslintrc --config .eslintrc.minimal.js --no-inline-config --format json src',
'npx eslint --config eslint.config.minimal.js --no-inline-config --format json src',
{
encoding: 'utf8',
maxBuffer: 50 * 1024 * 1024,
@@ -425,6 +425,7 @@ const ResultSet = ({
url: makeUrl('/api/v1/sqllab/export_streaming/'),
payload: { client_id: query.id },
exportType: 'csv',
exportSource: 'sqllab',
expectedRows: rows,
});
} else {
@@ -0,0 +1,113 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import { JsonObject, QueryFormData, VizType } from '@superset-ui/core';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
/**
* Integration (mocked-network) port of the deprecated Cypress spec
* `cypress/e2e/dashboard/_skip.url_params.test.ts` (sc-107448).
*
* The original test loaded a dashboard with query-string params, intercepted
* `/api/v1/chart/data`, and asserted each query in the request body carried
* `url_params`. That assertion is request-construction logic the form_data
* query-context pipeline which is exercised here without a backend.
*
* Intentional narrowing: the URL-string `form_data.url_params` hop (handled
* in `src/dashboard/actions/hydrate.ts` via `extractUrlParams`) is not covered
* here. This file verifies the chart-data side of the contract only; the
* dashboard hydration side is covered by its own unit tests.
*/
const CHART_DATA_GLOB = 'glob:*/api/v1/chart/data*';
const CHART_DATA_ROUTE = 'urlParamsForwarding-chartData';
const URL_PARAMS = { param1: '123', param2: 'abc' };
type ChartDataRequestBody = {
queries: JsonObject[];
form_data: JsonObject;
};
const buildFormData = (
overrides: Partial<QueryFormData> = {},
): QueryFormData => ({
datasource: '1__table',
granularity_sqla: 'ds',
viz_type: VizType.Table,
url_params: URL_PARAMS,
...overrides,
});
const lastChartDataBody = (): ChartDataRequestBody => {
const calls = fetchMock.callHistory.calls(CHART_DATA_ROUTE);
expect(calls.length).toBeGreaterThan(0);
return JSON.parse(
calls[calls.length - 1].options.body as string,
) as ChartDataRequestBody;
};
beforeEach(() => {
fetchMock.post(
CHART_DATA_GLOB,
{ result: [{ data: [] }] },
{
name: CHART_DATA_ROUTE,
},
);
});
// Remove only this file's route so global routes registered in
// setupSupersetClient (e.g. CSRF) survive into the next test.
afterEach(() => {
fetchMock.clearHistory();
fetchMock.removeRoutes({ names: [CHART_DATA_ROUTE] });
});
test('forwards url_params from form_data onto each query in the chart-data request body', async () => {
await getChartDataRequest({ formData: buildFormData() });
const body = lastChartDataBody();
expect(Array.isArray(body.queries)).toBe(true);
expect(body.queries.length).toBeGreaterThan(0);
body.queries.forEach(query => {
expect(query.url_params).toEqual(URL_PARAMS);
});
});
test('preserves url_params on form_data echoed back in the chart-data request body', async () => {
await getChartDataRequest({ formData: buildFormData() });
const body = lastChartDataBody();
expect(body.form_data.url_params).toEqual(URL_PARAMS);
});
// buildQueryObject defaults missing url_params to `{}` (see
// packages/superset-ui-core/src/query/buildQueryObject.ts), so the chart-data
// request body carries an empty object — not `undefined`. This test documents
// that contract; a future change that flips the default should update both.
test('emits an empty url_params object on each query when form_data has none', async () => {
await getChartDataRequest({
formData: buildFormData({ url_params: undefined }),
});
const body = lastChartDataBody();
expect(body.queries.length).toBeGreaterThan(0);
body.queries.forEach(query => {
expect(query.url_params).toEqual({});
});
});
@@ -0,0 +1,287 @@
/**
* 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 { act, render, screen } from 'spec/helpers/testing-library';
import { chat } from 'src/core/chat';
import ChatMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
act(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
// Reset host-owned state shared across tests in this module.
chat.close();
chat.setDisplayMode('floating');
});
});
test('renders nothing when no chat extension is registered', () => {
render(<ChatMount />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('renders the trigger bubble of the registered chat', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
// The panel stays unmounted until the chat is opened.
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('mounts the panel when the chat opens and unmounts it on close', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
act(() => chat.open());
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
// In floating mode the trigger stays mounted alongside the open panel.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('renders the last-registered chat when several are installed', () => {
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
),
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
render(<ChatMount />);
// Last-loaded wins: the second registration takes over the singleton slot.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
});
test('reacts to a chat registering after the initial render', () => {
render(<ChatMount />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
});
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a takeover mounts the incoming chat closed', () => {
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
),
);
render(<ChatMount />);
act(() => chat.open());
expect(screen.getByText('First Panel')).toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
});
// The displaced chat's open state must not leak into the winner.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
});
test('panel mode docks the open panel and hides the trigger', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument();
act(() => chat.close());
// A closed chat in panel mode renders nothing — the trigger is hidden too.
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('a crashing panel does not take the trigger down with it', () => {
const FailingPanel = () => {
throw new Error('panel blew up');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <FailingPanel />,
),
);
render(<ChatMount />);
act(() => chat.open());
// The panel's boundary contains the crash; the trigger keeps rendering so
// the user is not stranded without a way back.
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('isolates a failing trigger so it does not crash the host', () => {
const FailingTrigger = () => {
throw new Error('chat blew up');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
),
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatMount />)).not.toThrow();
// The mount slot still renders (the boundary lives inside it), confirming
// the provider was actually exercised and contained.
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('isolates a chat whose provider function itself throws', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => {
throw new Error('provider blew up');
},
() => <div>Acme Panel</div>,
),
);
// ChatRenderer wraps provider() in a component so ErrorBoundary catches
// synchronous throws from the provider function, not just from its output.
expect(() => render(<ChatMount />)).not.toThrow();
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different chat takes over', () => {
const FailingTrigger = () => {
throw new Error('first chat blew up');
};
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <FailingTrigger />,
() => <div>First Panel</div>,
),
);
render(<ChatMount />);
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
});
// The boundary is keyed per registration, so the latched crash from the
// first chat does not blank the second one.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
});
test('recovers when a crashed chat re-registers a fixed version under the same id', () => {
const FailingTrigger = () => {
throw new Error('broken release');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <div>Fixed Bubble</div>,
() => <div>Acme Panel</div>,
),
);
});
// Same id, new registrationId: the remounted boundary renders the fix.
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
});
@@ -0,0 +1,149 @@
/**
* 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 ReactElement, useRef, useSyncExternalStore } from 'react';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
import { getChatSnapshot, subscribeToChatState } from 'src/core/chat';
const CHAT_EDGE_MARGIN = 24;
const PANEL_MODE_WIDTH = 400;
/**
* Wraps a chat provider in a React component so that ErrorBoundary can catch
* synchronous throws from the provider function itself. Calling `provider()`
* inline (e.g. `{activeChat.panel()}`) would throw outside React's render
* boundary and crash the host.
*/
const ChatRenderer = ({ provider }: { provider: () => ReactElement }) =>
provider();
const ChatMount = () => {
const theme = useTheme();
// Notify at most once per registration; a crash can re-render and would
// otherwise re-toast, while a replacement (new registrationId) deserves a
// fresh notification if it crashes too.
const crashNotifiedFor = useRef<number | null>(null);
// The active chat, the open state, and the display mode are read from one
// immutable registry snapshot so a render never mixes state from two
// different store versions (the tearing useSyncExternalStore prevents).
const {
open: panelOpen,
mode,
active,
} = useSyncExternalStore(subscribeToChatState, getChatSnapshot);
if (!active) {
return null;
}
const { registrationId } = active;
const onProviderError = (error: Error) => {
// Fault isolation: contain the crash, log it, surface a one-time
// notification, and leave the slot empty rather than parking a
// persistent error card.
logging.error('[chat] provider crashed', error);
if (crashNotifiedFor.current !== registrationId) {
crashNotifiedFor.current = registrationId;
store.dispatch(addDangerToast(t('The chat failed to load.')));
}
};
if (mode === 'panel') {
// Panel mode hides the trigger and docks the panel to the right edge.
// Interim approximation of the "layout slot between header and footer"
// from the chat API contract — the dock overlays the page until the host
// grows a real layout slot and resizer chrome.
if (!panelOpen) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: ${PANEL_MODE_WIDTH}px;
background: ${theme.colorBgContainer};
box-shadow: ${theme.boxShadow};
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
<ErrorBoundary
key={registrationId}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.panel} />
</ErrorBoundary>
</div>
);
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
right: ${CHAT_EDGE_MARGIN}px;
bottom: ${CHAT_EDGE_MARGIN}px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: ${theme.sizeUnit * 2}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
{/*
Each provider gets its own boundary so a crashing panel cannot take
the trigger down with it (the trigger is the user's only way back).
Keyed by registrationId: Superset's ErrorBoundary latches its error
state, so a takeover, fallback, or same-id re-registration must
remount the boundary to recover.
*/}
{panelOpen && (
<ErrorBoundary
key={`panel-${registrationId}`}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.panel} />
</ErrorBoundary>
)}
<ErrorBoundary
key={`trigger-${registrationId}`}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.trigger} />
</ErrorBoundary>
</div>
);
};
export default ChatMount;
@@ -295,6 +295,7 @@ export interface ListViewProps<T extends object = any> {
name: ReactNode;
onSelect: (rows: any[]) => any;
type?: 'primary' | 'secondary' | 'danger';
hidden?: (rows: any[]) => boolean;
}>;
bulkSelectEnabled?: boolean;
disableBulkSelect?: () => void;
@@ -509,7 +510,16 @@ export function ListView<T extends object = any>({
{t('Deselect all')}
</span>
<div className="divider" />
{bulkActions.map(action => (
{bulkActions
.filter(
action =>
!action.hidden?.(
selectedFlatRows.map(
(r: any) => r.original,
),
),
)
.map(action => (
<Button
data-test="bulk-select-action"
data-test-action-key={action.key}
@@ -48,11 +48,11 @@ global.URL.revokeObjectURL = jest.fn();
global.fetch = jest.fn();
const { SupersetClient } = jest.requireMock('@superset-ui/core');
beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn();
const { SupersetClient } = jest.requireMock('@superset-ui/core');
SupersetClient.getCSRFToken.mockResolvedValue('mock-csrf-token');
SupersetClient.getGuestToken.mockReturnValue(undefined);
});
@@ -228,6 +228,7 @@ test('sets ERROR status and calls onError when fetch rejects', async () => {
// URL prefix guard tests - prevent regression of missing app root prefix
const { applicationRoot } = jest.requireMock('src/utils/getBootstrapData');
const { makeUrl } = jest.requireMock('src/utils/pathUtils');
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const createPrefixTestMockFetch = () =>
jest.fn().mockResolvedValue({
@@ -242,6 +243,107 @@ const createPrefixTestMockFetch = () =>
},
});
test('guest-token chart exports skip CSRF fetch and include guest_token form field', async () => {
applicationRoot.mockReturnValue('');
SupersetClient.getGuestToken.mockReturnValue('guest-token');
SupersetClient.getCSRFToken.mockRejectedValue(new Error('CSRF forbidden'));
const csvData = new TextEncoder().encode('id,name\n1,Alice\n');
let readCount = 0;
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
headers: new Headers({
'Content-Disposition': 'attachment; filename="embedded.csv"',
}),
body: {
getReader: () => ({
read: jest.fn().mockImplementation(() => {
readCount += 1;
if (readCount === 1) {
return Promise.resolve({ done: false, value: csvData });
}
return Promise.resolve({ done: true, value: undefined });
}),
}),
},
});
global.fetch = mockFetch;
const { result } = renderHook(() => useStreamingExport());
act(() => {
result.current.startExport({
url: '/api/v1/chart/data',
payload: { datasource: '1__table', viz_type: 'table' },
exportType: 'csv',
exportSource: 'chart',
expectedRows: 100000,
});
});
await waitFor(() => {
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
});
expect(SupersetClient.getCSRFToken).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledTimes(1);
const [, requestInit] = mockFetch.mock.calls[0];
const body = requestInit.body as URLSearchParams;
expect(body.get('guest_token')).toBe('guest-token');
expect(body.get('expected_rows')).toBe('100000');
expect(body.get('form_data')).toBe(
JSON.stringify({ datasource: '1__table', viz_type: 'table' }),
);
});
test('non-guest chart exports fetch CSRF and include X-CSRFToken header', async () => {
applicationRoot.mockReturnValue('');
const csvData = new TextEncoder().encode('id,name\n1,Alice\n');
let readCount = 0;
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
headers: new Headers({
'Content-Disposition': 'attachment; filename="chart.csv"',
}),
body: {
getReader: () => ({
read: jest.fn().mockImplementation(() => {
readCount += 1;
if (readCount === 1) {
return Promise.resolve({ done: false, value: csvData });
}
return Promise.resolve({ done: true, value: undefined });
}),
}),
},
});
global.fetch = mockFetch;
const { result } = renderHook(() => useStreamingExport());
act(() => {
result.current.startExport({
url: '/api/v1/chart/data',
payload: { datasource: '1__table', viz_type: 'table' },
exportType: 'csv',
exportSource: 'chart',
});
});
await waitFor(() => {
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
});
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
const [, requestInit] = mockFetch.mock.calls[0];
expect(requestInit.headers).toMatchObject({
'X-CSRFToken': 'mock-csrf-token',
});
expect((requestInit.body as URLSearchParams).has('guest_token')).toBe(false);
});
test('chart streaming export includes guest token in form body when configured', async () => {
SupersetClient.getGuestToken.mockReturnValue('guest-token');
const mockFetch = createPrefixTestMockFetch();
@@ -254,6 +356,7 @@ test('chart streaming export includes guest token in form body when configured',
url: '/api/v1/chart/data',
payload: { datasource: '1__table', viz_type: 'table' },
exportType: 'csv',
exportSource: 'chart',
});
});
@@ -268,6 +371,70 @@ test('chart streaming export includes guest token in form body when configured',
);
});
test('SQL Lab exports fetch CSRF and omit guest_token even when guest token exists', async () => {
applicationRoot.mockReturnValue('');
SupersetClient.getGuestToken.mockReturnValue('guest-token');
const mockFetch = createPrefixTestMockFetch();
global.fetch = mockFetch;
const { result } = renderHook(() => useStreamingExport());
act(() => {
result.current.startExport({
url: '/api/v1/sqllab/export_streaming/',
payload: { client_id: 'test-id' },
exportType: 'csv',
exportSource: 'sqllab',
});
});
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
const [, requestInit] = mockFetch.mock.calls[0];
const body = requestInit.body as URLSearchParams;
expect(requestInit.headers).toMatchObject({
'X-CSRFToken': 'mock-csrf-token',
});
expect(body.get('client_id')).toBe('test-id');
expect(body.has('guest_token')).toBe(false);
});
test('guest tokens do not bypass CSRF for unclassified non-client exports', async () => {
applicationRoot.mockReturnValue('');
SupersetClient.getGuestToken.mockReturnValue('guest-token');
const mockFetch = createPrefixTestMockFetch();
global.fetch = mockFetch;
const { result } = renderHook(() => useStreamingExport());
act(() => {
result.current.startExport({
url: '/api/v1/other/export_streaming/',
payload: { export_id: 'test-id' },
exportType: 'csv',
});
});
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
const [, requestInit] = mockFetch.mock.calls[0];
const body = requestInit.body as URLSearchParams;
expect(requestInit.headers).toMatchObject({
'X-CSRFToken': 'mock-csrf-token',
});
expect(body.has('guest_token')).toBe(false);
});
test('URL prefix guard applies prefix to unprefixed relative URL when app root is configured', async () => {
const appRoot = '/superset';
applicationRoot.mockReturnValue(appRoot);
@@ -31,6 +31,8 @@ interface StreamingExportPayload {
[key: string]: any;
}
type StreamingExportSource = 'chart' | 'sqllab';
interface StreamingExportParams {
/**
* The API endpoint URL for the export request.
@@ -46,6 +48,7 @@ interface StreamingExportParams {
payload: StreamingExportPayload;
filename?: string;
exportType: 'csv' | 'xlsx';
exportSource?: StreamingExportSource;
expectedRows?: number;
}
@@ -95,6 +98,7 @@ const createFetchRequest = async (
payload: StreamingExportPayload,
filename: string | undefined,
_exportType: string,
exportSource: StreamingExportSource | undefined,
expectedRows: number | undefined,
signal: AbortSignal,
): Promise<RequestInit> => {
@@ -102,10 +106,19 @@ const createFetchRequest = async (
'Content-Type': 'application/x-www-form-urlencoded',
};
// Get CSRF token using SupersetClient
const csrfToken = await SupersetClient.getCSRFToken();
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
const guestToken = SupersetClient.getGuestToken();
const isGuestTokenChartExport =
Boolean(guestToken) &&
exportSource === 'chart' &&
!('client_id' in payload);
// Embedded guest sessions cannot fetch CSRF tokens. Guest chart exports are
// safe because chart data is CSRF-exempt and auth is carried by guest_token.
if (!isGuestTokenChartExport) {
const csrfToken = await SupersetClient.getCSRFToken();
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
}
const formParams: Record<string, string> = {};
@@ -118,8 +131,7 @@ const createFetchRequest = async (
formParams.expected_rows = expectedRows.toString();
}
const guestToken = SupersetClient.getGuestToken();
if (guestToken) {
if (guestToken && isGuestTokenChartExport) {
formParams.guest_token = guestToken;
}
@@ -185,7 +197,8 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
const executeExport = useCallback(
async (params: StreamingExportParams) => {
const { url, payload, filename, exportType, expectedRows } = params;
const { url, payload, filename, exportType, exportSource, expectedRows } =
params;
if (isExportingRef.current) {
return;
}
@@ -210,6 +223,7 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
payload,
filename,
exportType,
exportSource,
expectedRows,
abortControllerRef.current.signal,
);
@@ -0,0 +1,327 @@
/**
* 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 { createElement } from 'react';
import { chat, getActiveChat, getChatSnapshot } from './index';
const disposables: Array<{ dispose: () => void }> = [];
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
// Reset host-owned state shared across tests in this module.
chat.close();
chat.setDisplayMode('floating');
});
test('getChat returns undefined when no chat is registered', () => {
expect(chat.getChat()).toBeUndefined();
expect(getActiveChat()).toBeUndefined();
});
test('registerChat resolves the registered chat with its providers', () => {
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
disposables.push(chat.registerChat(descriptor, trigger, panel));
expect(chat.getChat()).toEqual(descriptor);
expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel });
});
test('getChat returns a copy that cannot mutate the registry', () => {
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel),
);
const copy = chat.getChat();
copy!.name = 'Hijacked';
expect(chat.getChat()?.name).toBe('Acme Chat');
});
test('the last-registered chat wins when multiple are installed', () => {
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
expect(chat.getChat()?.id).toBe('second.chat');
});
test('disposing the active chat falls back to the previous registration', () => {
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
);
const second = chat.registerChat(
{ id: 'second.chat', name: 'Second' },
trigger,
panel,
);
expect(chat.getChat()?.id).toBe('second.chat');
second.dispose();
expect(chat.getChat()?.id).toBe('first.chat');
});
test('re-registering an id replaces the previous registration', () => {
const stalePanel = () => createElement('div', null, 'Stale');
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel),
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
expect(chat.getChat()?.name).toBe('Acme v2');
expect(getActiveChat()?.panel).toBe(panel);
});
test('each registration gets a distinct registrationId, including same-id replacements', () => {
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
const first = getActiveChat()?.registrationId;
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
const second = getActiveChat()?.registrationId;
expect(first).toBeDefined();
expect(second).toBeDefined();
expect(second).not.toBe(first);
});
test('disposing a registration twice unregisters only once', () => {
const unregistered = jest.fn();
disposables.push(chat.onDidUnregisterChat(unregistered));
const registration = chat.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
registration.dispose();
registration.dispose();
expect(unregistered).toHaveBeenCalledTimes(1);
expect(chat.getChat()).toBeUndefined();
});
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
const registered = jest.fn();
const unregistered = jest.fn();
disposables.push(
chat.onDidRegisterChat(registered),
chat.onDidUnregisterChat(unregistered),
);
const descriptor = { id: 'acme.chat', name: 'Acme' };
const registration = chat.registerChat(descriptor, trigger, panel);
expect(registered).toHaveBeenCalledWith(descriptor);
expect(unregistered).not.toHaveBeenCalled();
registration.dispose();
expect(unregistered).toHaveBeenCalledWith(descriptor);
});
test('a disposed event subscription stops receiving notifications', () => {
const registered = jest.fn();
const subscription = chat.onDidRegisterChat(registered);
subscription.dispose();
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
expect(registered).not.toHaveBeenCalled();
});
test('open and close toggle the panel and fire once', () => {
const opened = jest.fn();
const closed = jest.fn();
disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed));
const descriptor = { id: 'acme.chat', name: 'Acme' };
disposables.push(chat.registerChat(descriptor, trigger, panel));
expect(chat.isOpen()).toBe(false);
chat.open();
// Opening an already-open panel is a no-op and must not re-fire.
chat.open();
expect(chat.isOpen()).toBe(true);
expect(opened).toHaveBeenCalledTimes(1);
chat.close();
chat.close();
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('open is a no-op while no chat is registered', () => {
const opened = jest.fn();
disposables.push(chat.onDidOpen(opened));
chat.open();
expect(chat.isOpen()).toBe(false);
expect(opened).not.toHaveBeenCalled();
// A registration arriving later therefore starts closed.
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
expect(chat.isOpen()).toBe(false);
});
test('a takeover by a different id closes the displaced chat panel', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
const first = { id: 'first.chat', name: 'First' };
disposables.push(chat.registerChat(first, trigger, panel));
chat.open();
disposables.push(
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
// The incoming chat must not mount into an open state it never requested.
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('a same-id replacement keeps the open state', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
chat.open();
// Upgrade in place: same id, new providers.
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
expect(chat.isOpen()).toBe(true);
expect(closed).not.toHaveBeenCalled();
});
test('disposing the active chat while open closes it; the fallback starts closed', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
);
const second = { id: 'second.chat', name: 'Second' };
const registration = chat.registerChat(second, trigger, panel);
chat.open();
registration.dispose();
expect(chat.getChat()?.id).toBe('first.chat');
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('disposing an inactive registration leaves the open state untouched', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
const inactive = chat.registerChat(
{ id: 'first.chat', name: 'First' },
trigger,
panel,
);
disposables.push(
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
chat.open();
inactive.dispose();
expect(chat.isOpen()).toBe(true);
expect(closed).not.toHaveBeenCalled();
});
test('disposing the last chat while open resets the open state', () => {
const registration = chat.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
chat.open();
expect(chat.isOpen()).toBe(true);
registration.dispose();
expect(chat.isOpen()).toBe(false);
// A registration arriving much later must not inherit a stale open state.
disposables.push(
chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel),
);
expect(chat.isOpen()).toBe(false);
});
test('mode defaults to floating and setDisplayMode fires only on change', () => {
const modeChanged = jest.fn();
disposables.push(chat.onDidChangeDisplayMode(modeChanged));
expect(chat.getDisplayMode()).toBe('floating');
// Setting the current mode is a no-op.
chat.setDisplayMode('floating');
expect(modeChanged).not.toHaveBeenCalled();
chat.setDisplayMode('panel');
expect(chat.getDisplayMode()).toBe('panel');
expect(modeChanged).toHaveBeenCalledWith('panel');
});
test('the snapshot is immutable per version and consistent with the registry', () => {
const before = getChatSnapshot();
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
chat.open();
const after = getChatSnapshot();
// Unchanged references for old snapshots; a new object per change.
expect(after).not.toBe(before);
expect(before.active).toBeUndefined();
expect(after).toMatchObject({
open: true,
mode: 'floating',
active: getActiveChat(),
});
expect(after.version).toBeGreaterThan(before.version);
// Stable reference between changes.
expect(getChatSnapshot()).toBe(after);
});
+240
View File
@@ -0,0 +1,240 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host implementation of the `chat` contribution type.
*
* Chat is a dedicated contribution type, not a view: extensions register via
* the public `chat.registerChat()` and the host owns mounting, open/close
* state, and the display mode. Multiple chat extensions may register, but the
* host applies singleton resolution the most-recently-registered chat is
* active; disposing it falls back to the previous one.
*
* Open-state policy across active-chat transitions: when the active chat's
* identity changes a takeover by a different id, disposal falling back to a
* different id, or disposal of the last chat the panel is closed (firing
* `onDidClose`) so the incoming chat never mounts into an open state it did
* not request. A same-id re-registration is an upgrade in place and keeps the
* open state.
*
* The public namespace (`chat`) is exposed to extensions on
* `window.superset`; the other exports are host-internal accessors for
* ChatMount and are NOT part of the public `@apache-superset/core` API.
*/
import { ReactElement } from 'react';
import type { chat as chatApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createValueEventEmitter, createEventEmitter } from '../utils';
type Chat = chatApi.Chat;
type DisplayMode = chatApi.DisplayMode;
/** A registered chat: its descriptor plus the host-mountable providers. */
export interface RegisteredChat {
/** The chat descriptor passed to `registerChat`. */
chat: Chat;
/** Renders the collapsed bubble. Hidden by the host in panel mode. */
trigger: () => ReactElement;
/** Renders the chat panel, mounted per the current {@link DisplayMode}. */
panel: () => ReactElement;
/**
* Unique per registration (a same-id re-registration gets a new one). The
* host UI keys mounts and fault containment on it, so a replacement resets
* crashed error boundaries instead of inheriting their latched state.
*/
registrationId: number;
}
/**
* Immutable snapshot of the whole chat state, rebuilt on every change.
* Returned by reference from `getChatSnapshot` so `useSyncExternalStore`
* consumers read registrations, open state, and mode from one consistent
* object instead of tearing across separate live reads.
*/
export interface ChatSnapshot {
/** Monotonic change counter, useful as a memo/effect dependency. */
version: number;
/** Whether the active chat's panel is open. */
open: boolean;
/** The current display mode. */
mode: DisplayMode;
/** The active registration, or undefined when none is registered. */
active: RegisteredChat | undefined;
}
/** Registration order is the singleton-resolution order: last entry wins. */
const registrations: RegisteredChat[] = [];
let panelOpen = false;
let nextRegistrationId = 1;
const registerEmitter = createEventEmitter<Chat>();
const unregisterEmitter = createEventEmitter<Chat>();
const openEmitter = createEventEmitter<void>();
const closeEmitter = createEventEmitter<void>();
const resizePanelEmitter = createEventEmitter<{ width: number }>();
const modeEmitter = createValueEventEmitter<DisplayMode>('floating');
/**
* Host-internal: resolves the active chat with its providers.
* The most-recently-registered chat wins; when it is disposed the previous
* registration takes over the slot again.
*/
export const getActiveChat = (): RegisteredChat | undefined =>
registrations[registrations.length - 1];
let snapshot: ChatSnapshot = {
version: 0,
open: false,
mode: modeEmitter.getCurrent(),
active: undefined,
};
const stateSubscribers = new Set<() => void>();
const notifyState = () => {
snapshot = {
version: snapshot.version + 1,
open: panelOpen,
mode: modeEmitter.getCurrent(),
active: getActiveChat(),
};
stateSubscribers.forEach(fn => fn());
};
export const subscribeToChatState = (listener: () => void): (() => void) => {
stateSubscribers.add(listener);
return () => {
stateSubscribers.delete(listener);
};
};
export const getChatSnapshot = (): ChatSnapshot => snapshot;
/** Closes the panel and fires `onDidClose`. */
const closePanel = () => {
panelOpen = false;
closeEmitter.fire();
};
const registerChat: typeof chatApi.registerChat = (
chat: Chat,
trigger: () => ReactElement,
panel: () => ReactElement,
): Disposable => {
const previousActive = getActiveChat();
// Re-registering an id replaces the previous entry and moves it to the
// most-recent position, mirroring the view registry's same-id semantics.
const existingIndex = registrations.findIndex(r => r.chat.id === chat.id);
if (existingIndex !== -1) {
registrations.splice(existingIndex, 1);
}
const entry: RegisteredChat = {
chat,
trigger,
panel,
registrationId: nextRegistrationId,
};
nextRegistrationId += 1;
registrations.push(entry);
registerEmitter.fire(chat);
// A takeover by a different id closes the displaced chat's panel so the
// incoming chat never mounts already-open; a same-id replacement is an
// upgrade in place and keeps the open state.
if (panelOpen && previousActive && previousActive.chat.id !== chat.id) {
closePanel();
}
notifyState();
return new Disposable(() => {
const index = registrations.indexOf(entry);
if (index === -1) {
// Already removed — replaced by a same-id registration or disposed twice.
return;
}
const wasActive = getActiveChat() === entry;
registrations.splice(index, 1);
unregisterEmitter.fire(chat);
// Disposing the active chat closes its panel; the fallback chat (if any)
// starts closed. Disposing an inactive registration leaves the open
// state of the active chat untouched.
if (panelOpen && wasActive) {
closePanel();
}
notifyState();
});
};
const getChat: typeof chatApi.getChat = (): Chat | undefined => {
const active = getActiveChat();
// Copy so extensions cannot mutate another extension's descriptor.
return active ? { ...active.chat } : undefined;
};
const open: typeof chatApi.open = (): void => {
const active = getActiveChat();
// Open state only exists while a chat is registered; opening an empty slot
// would otherwise leak `open` into a future, unrelated registration.
if (panelOpen || !active) return;
panelOpen = true;
openEmitter.fire();
notifyState();
};
const close: typeof chatApi.close = (): void => {
const active = getActiveChat();
if (!panelOpen || !active) return;
closePanel();
notifyState();
};
const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen;
const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode =>
modeEmitter.getCurrent();
const setDisplayMode: typeof chatApi.setDisplayMode = (
displayMode: DisplayMode,
): void => {
if (displayMode === modeEmitter.getCurrent()) return;
modeEmitter.fire(displayMode);
notifyState();
};
export const chat: typeof chatApi = {
registerChat,
getChat,
onDidRegisterChat: registerEmitter.subscribe,
onDidUnregisterChat: unregisterEmitter.subscribe,
open,
close,
isOpen,
onDidOpen: openEmitter.subscribe,
onDidClose: closeEmitter.subscribe,
getDisplayMode,
setDisplayMode,
onDidChangeDisplayMode: modeEmitter.subscribe,
// The host fires this from its panel resizer; until that chrome exists the
// event is exposed but never fires.
onDidResizePanel: resizePanelEmitter.subscribe,
};
@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
});
test('handles errors in event listeners gracefully', () => {
const manager = EditorProviders.getInstance();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const errorListener = jest.fn(() => {
throw new Error('Listener error');
});
const successListener = jest.fn();
manager.onDidRegister(errorListener);
manager.onDidRegister(successListener);
manager.registerProvider(createMockEditor(), createMockEditorComponent());
// Both listeners should have been called
expect(errorListener).toHaveBeenCalledTimes(1);
expect(successListener).toHaveBeenCalledTimes(1);
// Error should have been logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error in event listener:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
test('reset clears all providers and language mappings', () => {
const manager = EditorProviders.getInstance();
@@ -19,6 +19,7 @@
import type { editors } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type EditorLanguage = editors.EditorLanguage;
type EditorProvider = editors.EditorProvider;
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
/**
* Listener function type for events.
*/
type Listener<T> = (e: T) => void;
/**
* Simple event emitter for editor provider lifecycle events.
*/
class EventEmitter<T> {
private listeners: Set<Listener<T>> = new Set();
/**
* Subscribe to this event.
* @param listener The listener function to call when the event is fired.
* @returns A Disposable to unsubscribe from the event.
*/
subscribe(listener: Listener<T>): Disposable {
this.listeners.add(listener);
return new Disposable(() => {
this.listeners.delete(listener);
});
}
/**
* Fire the event with the given data.
* @param data The event data to pass to listeners.
*/
fire(data: T): void {
this.listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error in event listener:', error);
}
});
}
}
/**
* Singleton manager for editor providers.
* Handles registration, resolution, and lifecycle of custom editor implementations.
@@ -83,15 +47,9 @@ class EditorProviders {
*/
private languageToProvider: Map<EditorLanguage, string> = new Map();
/**
* Event emitter for provider registration events.
*/
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
/**
* Event emitter for provider unregistration events.
*/
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
private syncListeners: Set<() => void> = new Set();
+2
View File
@@ -27,11 +27,13 @@ export const core: typeof coreType = {
};
export * from './authentication';
export * from './chat';
export * from './commands';
export * from './editors';
export * from './extensions';
export * from './menus';
export * from './models';
export * from './navigation';
export * from './sqlLab';
export * from './utils';
export * from './views';
+8 -12
View File
@@ -27,6 +27,7 @@
import { useSyncExternalStore } from 'react';
import type { menus as menusApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type MenuItem = menusApi.MenuItem;
type Menu = menusApi.Menu;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
const menuCache = new Map<string, Menu | undefined>();
const notifyRegister = (event: MenuItemRegisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event));
registerEmitter.fire(event);
};
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event));
unregisterEmitter.fire(event);
};
const registerMenuItem: typeof menusApi.registerMenuItem = (
@@ -117,16 +118,11 @@ export const useMenu = (location: string): Menu | undefined =>
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
listener: (e: MenuItemRegisteredEvent) => void,
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable =>
unregisterEmitter.subscribe(listener);
export const menus: typeof menusApi = {
registerMenuItem,
@@ -0,0 +1,124 @@
/**
* 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.
*/
// Reset module state between tests so currentPage is re-initialized.
beforeEach(() => {
jest.resetModules();
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/' },
});
});
async function importNavigation() {
const mod = await import('./index');
return mod;
}
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
const { navigation, notifyPageChange } = await importNavigation();
// The default pathname ('/') is not enumerated and falls back to home.
expect(navigation.getPage()).toBe('home');
notifyPageChange('/superset/welcome/');
expect(navigation.getPage()).toBe('home');
});
test('getPage derives the page from window.location.pathname', async () => {
window.location.pathname = '/superset/dashboard/42/';
const { navigation } = await importNavigation();
expect(navigation.getPage()).toBe('dashboard');
});
test('notifyPageChange updates the current page type', async () => {
const { navigation, notifyPageChange } = await importNavigation();
notifyPageChange('/explore/?form_data={}');
expect(navigation.getPage()).toBe('explore');
});
test('notifyPageChange fires listeners on page type change', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/1/');
expect(listener).toHaveBeenCalledWith('dashboard');
disposable.dispose();
});
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
window.location.pathname = '/superset/dashboard/1/';
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/2/');
expect(listener).not.toHaveBeenCalled();
});
test('onDidChangePage listener is removed after dispose', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
disposable.dispose();
notifyPageChange('/superset/dashboard/1/');
expect(listener).not.toHaveBeenCalled();
});
test('sqllab path is matched with and without trailing slash', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab');
expect(navigation.getPage()).toBe('sqllab');
notifyPageChange('/explore/');
notifyPageChange('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
});
test('chart and dashboard list pages get their own page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/list/');
expect(navigation.getPage()).toBe('chart_list');
notifyPageChange('/dashboard/list/');
expect(navigation.getPage()).toBe('dashboard_list');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/tablemodelview/list/');
expect(navigation.getPage()).toBe('dataset_list');
notifyPageChange('/dataset/42');
expect(navigation.getPage()).toBe('dataset');
});
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
notifyPageChange('/sqllab/history/');
expect(navigation.getPage()).toBe('query_history');
notifyPageChange('/savedqueryview/list/');
expect(navigation.getPage()).toBe('saved_queries');
});
test('chart/add resolves to explore, not chart_list', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/add');
expect(navigation.getPage()).toBe('explore');
});
@@ -0,0 +1,79 @@
/**
* 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.
*/
/**
* Host-internal implementation of the `navigation` namespace.
*
* Backed by browser location no Redux dependency.
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
*/
import type { navigation as navigationApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type Page = navigationApi.Page;
const pageChangeEmitter = createEventEmitter<Page>();
function derivePage(pathname: string): Page {
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
if (pathname.startsWith('/explore/')) return 'explore';
if (pathname.startsWith('/superset/explore/')) return 'explore';
if (pathname.startsWith('/chart/add')) return 'explore';
if (pathname.startsWith('/chart/list')) return 'chart_list';
if (pathname.startsWith('/sqllab/history')) return 'query_history';
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
return 'sqllab';
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
if (pathname.startsWith('/dataset/')) return 'dataset';
// The welcome page and any route not explicitly enumerated fall back to home.
return 'home';
}
let currentPage: Page | undefined;
function getOrInitPage(): Page {
if (currentPage === undefined) {
currentPage = derivePage(window.location.pathname);
}
return currentPage;
}
/** Called by ExtensionsStartup whenever the React Router location changes. */
export const notifyPageChange = (pathname: string): void => {
const next = derivePage(pathname);
if (next === getOrInitPage()) return;
currentPage = next;
pageChangeEmitter.fire(next);
};
const getPage: typeof navigationApi.getPage = () => getOrInitPage();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (page: Page) => void,
thisArgs?: any,
): Disposable => pageChangeEmitter.subscribe(listener, thisArgs);
export const navigation: typeof navigationApi = {
getPage,
onDidChangePage,
};
+51
View File
@@ -21,6 +21,57 @@ import { AnyAction } from 'redux';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
type Listener<T> = (e: T) => unknown;
/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
export interface EventEmitter<T> {
/** Notifies every current subscriber with `value`. */
fire(value: T): void;
/** Registers a listener; returns a Disposable that removes it. */
subscribe: core.Event<T>;
}
/** An event emitter that also retains the last fired value. */
export interface ValueEventEmitter<T> extends EventEmitter<T> {
/** Returns the value last passed to {@link fire} (or the initial value). */
getCurrent(): T;
}
/**
* Creates a stateless event emitter. Listeners registered via `event` receive
* every subsequent `fire`; a returned Disposable removes the listener.
*/
export function createEventEmitter<T>(): EventEmitter<T> {
const listeners = new Set<Listener<T>>();
const subscribe: core.Event<T> = (listener, thisArgs) => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return { dispose: () => listeners.delete(bound) };
};
return {
fire: value => listeners.forEach(fn => fn(value)),
subscribe,
};
}
/**
* Creates a value event emitter seeded with `initial`. Behaves like
* {@link createEventEmitter} but also tracks the last fired value, readable
* via `getCurrent` useful for state that is both observed and queried.
*/
export function createValueEventEmitter<T>(initial: T): ValueEventEmitter<T> {
const { fire, subscribe } = createEventEmitter<T>();
let current = initial;
return {
fire: value => {
current = value;
fire(value);
},
subscribe,
getCurrent: () => current,
};
}
export function createActionListener<V>(
predicate: AnyListenerPredicate<RootState>,
listener: (v: V) => void,
+7 -12
View File
@@ -29,6 +29,7 @@ import type { views as viewsApi } from '@apache-superset/core';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type View = viewsApi.View;
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
const viewsCache = new Map<string, View[] | undefined>();
const notifyRegister = (event: ViewRegisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event));
registerEmitter.fire(event);
};
const notifyUnregister = (event: ViewUnregisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event));
unregisterEmitter.fire(event);
};
const registerView: typeof viewsApi.registerView = (
@@ -116,17 +117,11 @@ export const useViews = (location: string): View[] | undefined =>
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
listener: (e: ViewRegisteredEvent) => void,
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
listener: (e: ViewUnregisteredEvent) => void,
): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
): Disposable => unregisterEmitter.subscribe(listener);
export const views: typeof viewsApi = {
registerView,
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, ReactNode } from 'react';
import { ReactNode, useCallback, useContext, useEffect, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core';
@@ -90,165 +90,61 @@ interface VisibilityEventData {
ts: number;
}
class Dashboard extends PureComponent<DashboardProps> {
static contextType = PluginContext;
function unload(event: BeforeUnloadEvent): string {
const message = t('You have unsaved changes.');
// Set returnValue on the actual event object to trigger the browser prompt
event.returnValue = message;
return message; // Gecko + Webkit, Safari, Chrome etc.
}
// Use type assertion when accessing context instead of declare field
// to avoid babel transformation issues in Jest
static defaultProps = {
timeout: 60,
userId: '',
};
appliedFilters: ActiveFilters;
appliedOwnDataCharts: JsonObject;
visibilityEventData: VisibilityEventData;
static onBeforeUnload(hasChanged: boolean): void {
if (hasChanged) {
window.addEventListener('beforeunload', Dashboard.unload);
} else {
window.removeEventListener('beforeunload', Dashboard.unload);
}
function onBeforeUnload(hasChanged: boolean): void {
if (hasChanged) {
window.addEventListener('beforeunload', unload);
} else {
window.removeEventListener('beforeunload', unload);
}
}
static unload(): string {
const message = t('You have unsaved changes.');
// Gecko + IE: returnValue is typed as boolean but historically accepts string
(window.event as BeforeUnloadEvent).returnValue = message;
return message; // Gecko + Webkit, Safari, Chrome etc.
}
function Dashboard({
actions,
dashboardId,
editMode,
isPublished,
hasUnsavedChanges,
slices,
activeFilters,
chartConfiguration,
datasources,
ownDataCharts,
layout,
impressionId,
timeout = 60,
userId = '',
children,
}: DashboardProps): JSX.Element {
const context = useContext(PluginContext) as PluginContextType;
constructor(props: DashboardProps) {
super(props);
this.appliedFilters = props.activeFilters ?? {};
this.appliedOwnDataCharts = props.ownDataCharts ?? {};
this.visibilityEventData = { start_offset: 0, ts: 0 };
this.onVisibilityChange = this.onVisibilityChange.bind(this);
}
// Use refs to track mutable values that persist across renders
const appliedFiltersRef = useRef<ActiveFilters>(activeFilters ?? {});
const appliedOwnDataChartsRef = useRef<JsonObject>(ownDataCharts ?? {});
const visibilityEventDataRef = useRef<VisibilityEventData>({
start_offset: 0,
ts: 0,
});
const prevLayoutRef = useRef<DashboardLayout>(layout);
const prevDashboardIdRef = useRef<number>(dashboardId);
componentDidMount(): void {
const bootstrapData = getBootstrapData();
const { editMode, isPublished, layout } = this.props;
const eventData: Record<string, unknown> = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
bootstrap_data_length: JSON.stringify(bootstrapData).length,
};
const directLinkComponentId = getLocationHash();
if (directLinkComponentId) {
eventData.target_id = directLinkComponentId;
}
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
// Handle browser tab visibility change
if (document.visibilityState === 'hidden') {
this.visibilityEventData = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
}
window.addEventListener('visibilitychange', this.onVisibilityChange);
this.applyCharts();
}
componentDidUpdate(prevProps: DashboardProps): void {
this.applyCharts();
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
const nextChartIds = getChartIdsFromLayout(this.props.layout);
if (prevProps.dashboardId !== this.props.dashboardId) {
// single-page-app navigation check
return;
}
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
this.props.actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(this.props.layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
this.props.actions.removeSliceFromDashboard(removedChartId),
);
}
}
applyCharts(): void {
const {
activeFilters,
ownDataCharts,
chartConfiguration,
hasUnsavedChanges,
editMode,
} = this.props;
const { appliedFilters, appliedOwnDataCharts } = this;
if (!chartConfiguration) {
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
// for correct comparing of filters to avoid unnecessary requests
return;
}
if (
!editMode &&
(!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, {
ignoreUndefined: true,
}) ||
!areObjectsEqual(appliedFilters, activeFilters, {
ignoreUndefined: true,
}))
) {
this.applyFilters();
}
if (hasUnsavedChanges) {
Dashboard.onBeforeUnload(true);
} else {
Dashboard.onBeforeUnload(false);
}
}
componentWillUnmount(): void {
window.removeEventListener('visibilitychange', this.onVisibilityChange);
this.props.actions.clearDataMaskState();
this.props.actions.clearAllChartStates();
}
onVisibilityChange(): void {
if (document.visibilityState === 'hidden') {
// from visible to hidden
this.visibilityEventData = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
} else if (document.visibilityState === 'visible') {
// from hidden to visible
const logStart = this.visibilityEventData.start_offset;
this.props.actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
...this.visibilityEventData,
duration: Logger.getTimestamp() - logStart,
const refreshCharts = useCallback(
(ids: (string | number)[]): void => {
ids.forEach(id => {
actions.triggerQuery(true, id);
});
}
}
},
[actions],
);
applyFilters(): void {
const { appliedFilters } = this;
const { activeFilters, ownDataCharts, slices } = this.props;
const applyFilters = useCallback((): void => {
const appliedFilters = appliedFiltersRef.current;
// refresh charts if a filter was removed, added, or changed
@@ -258,7 +154,7 @@ class Dashboard extends PureComponent<DashboardProps> {
const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys));
const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts(
ownDataCharts,
this.appliedOwnDataCharts,
appliedOwnDataChartsRef.current,
);
[...allKeys].forEach(filterKey => {
@@ -321,24 +217,157 @@ class Dashboard extends PureComponent<DashboardProps> {
});
// remove dup in affectedChartIds
this.refreshCharts([...new Set(affectedChartIds)]);
this.appliedFilters = activeFilters;
this.appliedOwnDataCharts = ownDataCharts;
}
refreshCharts([...new Set(affectedChartIds)]);
appliedFiltersRef.current = activeFilters;
appliedOwnDataChartsRef.current = ownDataCharts;
}, [activeFilters, ownDataCharts, slices, refreshCharts]);
refreshCharts(ids: (string | number)[]): void {
ids.forEach(id => {
this.props.actions.triggerQuery(true, id);
});
}
render(): ReactNode {
const context = this.context as PluginContextType;
if (context.loading) {
return <Loading />;
const applyCharts = useCallback((): void => {
if (!chartConfiguration) {
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
// for correct comparing of filters to avoid unnecessary requests
return;
}
return this.props.children;
if (
!editMode &&
(!areObjectsEqual(appliedOwnDataChartsRef.current, ownDataCharts, {
ignoreUndefined: true,
}) ||
!areObjectsEqual(appliedFiltersRef.current, activeFilters, {
ignoreUndefined: true,
}))
) {
applyFilters();
}
if (hasUnsavedChanges) {
onBeforeUnload(true);
} else {
onBeforeUnload(false);
}
}, [
chartConfiguration,
editMode,
ownDataCharts,
activeFilters,
hasUnsavedChanges,
applyFilters,
]);
const onVisibilityChange = useCallback((): void => {
if (document.visibilityState === 'hidden') {
// from visible to hidden
visibilityEventDataRef.current = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
} else if (document.visibilityState === 'visible') {
// from hidden to visible
const logStart = visibilityEventDataRef.current.start_offset;
actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
...visibilityEventDataRef.current,
duration: Logger.getTimestamp() - logStart,
});
}
}, [actions]);
// Refs that always point at the latest closures so the mount-only effect's
// listeners/cleanup never invoke a stale `actions` closure when `actions`
// identity changes.
const onVisibilityChangeRef = useRef(onVisibilityChange);
const actionsRef = useRef(actions);
useEffect(() => {
onVisibilityChangeRef.current = onVisibilityChange;
actionsRef.current = actions;
});
// componentDidMount equivalent
useEffect(() => {
const bootstrapData = getBootstrapData();
const eventData: Record<string, unknown> = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
bootstrap_data_length: JSON.stringify(bootstrapData).length,
};
const directLinkComponentId = getLocationHash();
if (directLinkComponentId) {
eventData.target_id = directLinkComponentId;
}
actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
// Handle browser tab visibility change
if (document.visibilityState === 'hidden') {
visibilityEventDataRef.current = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
}
const handleVisibilityChange = () => onVisibilityChangeRef.current();
document.addEventListener('visibilitychange', handleVisibilityChange);
// componentWillUnmount equivalent
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
onBeforeUnload(false); // Remove beforeunload listener on unmount
actionsRef.current.clearDataMaskState();
actionsRef.current.clearAllChartStates();
};
// Only run on mount/unmount - listeners/cleanup go through refs to avoid
// capturing stale closures.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Apply charts on every render (like componentDidMount + componentDidUpdate calling applyCharts)
useEffect(() => {
applyCharts();
}, [applyCharts]);
// componentDidUpdate equivalent for layout changes
useEffect(() => {
const prevLayout = prevLayoutRef.current;
const prevDashboardId = prevDashboardIdRef.current;
// Update refs for next comparison
prevLayoutRef.current = layout;
prevDashboardIdRef.current = dashboardId;
const currentChartIds = getChartIdsFromLayout(prevLayout);
const nextChartIds = getChartIdsFromLayout(layout);
if (prevDashboardId !== dashboardId) {
// single-page-app navigation check
return;
}
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
actions.removeSliceFromDashboard(removedChartId),
);
}
}, [layout, dashboardId, actions]);
if (context.loading) {
return <Loading />;
}
return <>{children}</>;
}
export default Dashboard;
@@ -165,6 +165,62 @@ describe('DashboardBuilder', () => {
expect(header).toBeInTheDocument();
});
test('should hide DashboardHeader when standalone mode hides nav and title (?standalone=2)', () => {
// React-level equivalent of the legacy `cy.get('#app-menu').should('not.exist')`
// Cypress assertion. The `#app-menu` node lives in Flask's spa.html template,
// gated by `{% if standalone_mode %}`, so RTL cannot reach it directly.
// `?standalone=2` maps to DashboardStandaloneMode.HideNavAndTitle, which the
// DashboardBuilder honours by suppressing the React-side DashboardHeader.
const originalHref = window.location.href;
window.history.replaceState({}, '', '/?standalone=2');
try {
const { queryByTestId } = setup();
expect(
queryByTestId('dashboard-header-container'),
).not.toBeInTheDocument();
} finally {
window.history.replaceState({}, '', originalHref);
}
});
test('should keep the DashboardHeader when standalone mode only hides nav (?standalone=1)', () => {
// `?standalone=1` maps to DashboardStandaloneMode.HideNav, which only hides the
// Flask-rendered global app menu (#app-menu) — it must NOT suppress the React-side
// DashboardHeader. This pins the boundary against HideNavAndTitle (?standalone=2).
const originalHref = window.location.href;
window.history.replaceState({}, '', '/?standalone=1');
try {
const { queryByTestId } = setup();
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
} finally {
window.history.replaceState({}, '', originalHref);
}
});
test('should keep the header hidden in standalone mode (?standalone=2) while editMode is active', () => {
// Orthogonality analogue of the legacy `?edit=true&standalone=true` Cypress mount.
// editMode is sourced from Redux (state.dashboardState.editMode), not the URL —
// DashboardBuilder only reads URL_PARAMS.standalone — so the legacy `edit=true`
// param is inert here and is intentionally omitted. Contract under test:
// standalone=2 (HideNavAndTitle) suppresses DashboardHeader even while editMode
// drives the `dashboard--editing` class on the wrapper.
const originalHref = window.location.href;
window.history.replaceState({}, '', '/?standalone=2');
try {
const { getByTestId, queryByTestId } = setup({
dashboardState: { ...mockState.dashboardState, editMode: true },
});
expect(getByTestId('dashboard-content-wrapper')).toHaveClass(
'dashboard dashboard--editing',
);
expect(
queryByTestId('dashboard-header-container'),
).not.toBeInTheDocument();
} finally {
window.history.replaceState({}, '', originalHref);
}
});
test('should render a Sticky top-level Tabs if the dashboard has tabs', async () => {
const { findAllByTestId } = setup({
dashboardLayout: undoableDashboardLayoutWithTabs,
@@ -31,9 +31,10 @@ export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => {
};
export const shouldFocusTabs = (
event: { target: { className: string } },
container: { contains: (arg0: any) => any },
) =>
event: { target: HTMLElement },
container: Pick<Node, 'contains'> | null,
_menuRef: HTMLDivElement | null,
): boolean =>
// don't focus the tabs when we click on a tab
event.target.className === 'ant-tabs-nav-wrap' ||
container.contains(event.target);
(container?.contains(event.target) ?? false);
@@ -16,12 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, Fragment } from 'react';
import { withTheme } from '@emotion/react';
import { Fragment, useCallback, useRef, useState } from 'react';
import classNames from 'classnames';
import { addAlpha } from '@superset-ui/core';
import { css, styled, type SupersetTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { EmptyState } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { navigateTo } from 'src/utils/navigationUtils';
@@ -48,11 +47,6 @@ export interface DashboardGridProps {
setEditMode?: (editMode: boolean) => void;
width: number;
dashboardId?: number;
theme: SupersetTheme;
}
interface DashboardGridState {
isResizing: boolean;
}
interface DropProps {
@@ -131,261 +125,235 @@ const GridColumnGuide = styled.div`
`};
`;
class DashboardGrid extends PureComponent<
DashboardGridProps,
DashboardGridState
> {
grid: HTMLDivElement | null;
function DashboardGrid({
depth,
editMode,
canEdit,
gridComponent,
handleComponentDrop,
isComponentVisible,
resizeComponent,
setDirectPathToChild,
setEditMode,
width,
dashboardId,
}: DashboardGridProps) {
const theme = useTheme();
const [isResizing, setIsResizing] = useState(false);
const gridRef = useRef<HTMLDivElement | null>(null);
constructor(props: DashboardGridProps) {
super(props);
this.state = {
isResizing: false,
};
this.grid = null;
this.handleResizeStart = this.handleResizeStart.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handleResizeStop = this.handleResizeStop.bind(this);
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
this.setGridRef = this.setGridRef.bind(this);
this.handleChangeTab = this.handleChangeTab.bind(this);
}
const setGridRef = useCallback((ref: HTMLDivElement | null): void => {
gridRef.current = ref;
}, []);
getRowGuidePosition(resizeRef: HTMLElement | null): number | null {
if (resizeRef && this.grid) {
return (
resizeRef.getBoundingClientRect().bottom -
this.grid.getBoundingClientRect().top -
2
);
}
return null;
}
const handleResizeStart = useCallback((): void => {
setIsResizing(true);
}, []);
setGridRef(ref: HTMLDivElement | null): void {
this.grid = ref;
}
const handleResize = useCallback(
(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
_delta: { width: number; height: number },
): void => {
// no-op: resize position tracking not implemented
},
[],
);
handleResizeStart(): void {
this.setState(() => ({
isResizing: true,
}));
}
handleResize(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
_delta: { width: number; height: number },
): void {
// no-op: resize position is tracked via getRowGuidePosition
}
handleResizeStop(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
): void {
this.props.resizeComponent({
id,
width: delta.width,
height: delta.height,
});
this.setState(() => ({
isResizing: false,
}));
}
handleTopDropTargetDrop(dropResult: DropResult): void {
if (dropResult?.destination) {
this.props.handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
const handleResizeStop = useCallback(
(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
): void => {
resizeComponent({
id,
width: delta.width,
height: delta.height,
});
}
}
handleChangeTab({ pathToTabIndex }: { pathToTabIndex: string[] }): void {
this.props.setDirectPathToChild(pathToTabIndex);
}
setIsResizing(false);
},
[resizeComponent],
);
render() {
const {
gridComponent,
handleComponentDrop,
depth,
width,
isComponentVisible,
editMode,
canEdit,
setEditMode,
dashboardId,
theme,
} = this.props;
const columnPlusGutterWidth =
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
const handleTopDropTargetDrop = useCallback(
(dropResult: DropResult): void => {
if (dropResult?.destination) {
handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
});
}
},
[handleComponentDrop],
);
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
const { isResizing } = this.state;
const handleChangeTab = useCallback(
({ pathToTabIndex }: { pathToTabIndex: string[] }): void => {
setDirectPathToChild(pathToTabIndex);
},
[setDirectPathToChild],
);
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
const shouldDisplayTopLevelTabEmptyState =
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
const dashboardEmptyState = editMode && (
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
);
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
const topLevelTabEmptyState = editMode ? (
<EmptyState
title={t('Drag and drop components to this tab')}
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={
canEdit && t('You can add the components in the edit mode.')
}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
const shouldDisplayTopLevelTabEmptyState =
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={this.setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={this.handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full':
gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={this.handleResizeStart}
onResize={this.handleResize}
onResizeStop={this.handleResizeStop}
onChangeTab={this.handleChangeTab}
const dashboardEmptyState = editMode && (
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
);
const topLevelTabEmptyState = editMode ? (
<EmptyState
title={t('Drag and drop components to this tab')}
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={canEdit && t('You can add the components in the edit mode.')}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full': gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={handleResizeStart}
onResize={handleResize}
onResizeStop={handleResizeStop}
onChangeTab={handleChangeTab}
/>
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
))}
</GridContent>
</div>
</>
);
}
))}
</GridContent>
</div>
</>
);
}
export default withTheme(DashboardGrid);
export default DashboardGrid;
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import { useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { Tooltip, PublishedLabel } from '@superset-ui/core/components';
import { HeaderProps, HeaderDropdownProps } from '../Header/types';
@@ -43,70 +43,64 @@ const publishedTooltip = t(
'This dashboard is published. Click to make it a draft.',
);
export default class PublishedStatus extends Component<DashboardPublishedStatusType> {
constructor(props: DashboardPublishedStatusType) {
super(props);
this.togglePublished = this.togglePublished.bind(this);
}
export default function PublishedStatus({
dashboardId,
userCanEdit,
userCanSave,
isPublished,
savePublished,
}: DashboardPublishedStatusType) {
const togglePublished = useCallback(() => {
savePublished(dashboardId, !isPublished);
}, [dashboardId, isPublished, savePublished]);
togglePublished() {
this.props.savePublished(this.props.dashboardId, !this.props.isPublished);
}
render() {
const { isPublished, userCanEdit, userCanSave } = this.props;
// Show everybody the draft badge
if (!isPublished) {
// if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftButtonTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={this.togglePublished}
/>
</div>
</Tooltip>
);
}
// Show everybody the draft badge
if (!isPublished) {
// if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
title={draftButtonTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={this.togglePublished}
onClick={togglePublished}
/>
</div>
</Tooltip>
);
}
// Don't show anything if one doesn't own the dashboard and it is published
return null;
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} onClick={togglePublished} />
</div>
</Tooltip>
);
}
// Don't show anything if one doesn't own the dashboard and it is published
return null;
}
@@ -17,13 +17,13 @@
* under the License.
*/
/* eslint-env browser */
import { Component } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
// @ts-expect-error
import { createFilter } from 'react-search-input';
import { t } from '@apache-superset/core/translation';
import { styled, css } from '@apache-superset/core/theme';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import {
Button,
Checkbox,
@@ -49,7 +49,6 @@ import {
import { debounce, pickBy } from 'lodash';
import { Dispatch } from 'redux';
import { Slice } from 'src/dashboard/types';
import { withTheme, Theme } from '@emotion/react';
import { navigateTo } from 'src/utils/navigationUtils';
import type { ConnectDragSource } from 'react-dnd';
import AddSliceCard from './AddSliceCard';
@@ -58,7 +57,6 @@ import { DragDroppable } from './dnd/DragDroppable';
import { datasetLabelLower } from 'src/features/semanticLayers/label';
export type SliceAdderProps = {
theme: Theme;
fetchSlices: (
userId?: number,
filter_value?: string,
@@ -77,14 +75,6 @@ export type SliceAdderProps = {
dashboardId: number;
};
type SliceAdderState = {
filteredSlices: Slice[];
searchTerm: string;
sortBy: keyof Slice;
selectedSliceIdsSet: Set<number>;
showOnlyMyCharts: boolean;
};
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
const KEYS_TO_SORT = {
slice_name: t('name'),
@@ -174,295 +164,308 @@ function getFilteredSortedSlices(
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
.sort(sortByComparator(sortBy));
}
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
private slicesRequest?: AbortController | Promise<void>;
static defaultProps = {
selectedSliceIds: [],
editMode: false,
errorMessage: '',
};
function SliceAdder({
fetchSlices,
updateSlices,
isLoading,
slices,
errorMessage = '',
userId,
selectedSliceIds = [],
editMode = false,
dashboardId,
}: SliceAdderProps) {
const theme = useTheme();
const slicesRequestRef = useRef<AbortController | Promise<void>>();
constructor(props: SliceAdderProps) {
super(props);
this.state = {
filteredSlices: [],
searchTerm: '',
sortBy: DEFAULT_SORT_KEY,
selectedSliceIdsSet: new Set(props.selectedSliceIds),
showOnlyMyCharts: getItem(
LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
true,
),
};
this.rowRenderer = this.rowRenderer.bind(this);
this.searchUpdated = this.searchUpdated.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.userIdForFetch = this.userIdForFetch.bind(this);
this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
}
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<keyof Slice>(DEFAULT_SORT_KEY);
const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState(
() => new Set(selectedSliceIds),
);
userIdForFetch() {
return this.state.showOnlyMyCharts ? this.props.userId : undefined;
}
// Refs to track latest values for cleanup effect
const latestSlicesRef = useRef(slices);
const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
);
componentDidMount() {
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
'',
this.state.sortBy,
);
}
// Keep refs updated with latest values
useEffect(() => {
latestSlicesRef.current = slices;
}, [slices]);
componentDidUpdate(prevProps: SliceAdderProps) {
const nextState: SliceAdderState = {} as SliceAdderState;
if (this.props.lastUpdated !== prevProps.lastUpdated) {
nextState.filteredSlices = getFilteredSortedSlices(
this.props.slices,
this.state.searchTerm,
this.state.sortBy,
this.state.showOnlyMyCharts,
this.props.userId,
);
}
useEffect(() => {
latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
}, [selectedSliceIdsSet]);
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
}
if (Object.keys(nextState).length) {
this.setState(nextState);
}
}
componentWillUnmount() {
// Clears the redux store keeping only selected items
const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
this.state.selectedSliceIdsSet.has(value.slice_id),
);
this.props.updateSlices(selectedSlices);
if (this.slicesRequest instanceof AbortController) {
this.slicesRequest.abort();
}
}
handleChange = debounce(value => {
this.searchUpdated(value);
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
value,
this.state.sortBy,
);
}, 300);
searchUpdated(searchTerm: string) {
this.setState(prevState => ({
searchTerm,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
const filteredSlices = useMemo(
() =>
getFilteredSortedSlices(
slices,
searchTerm,
prevState.sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
}
handleSelect(sortBy: keyof Slice) {
this.setState(prevState => ({
sortBy,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
this.state.searchTerm,
sortBy,
);
}
rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) {
const { filteredSlices, selectedSliceIdsSet } = this.state;
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={this.props.editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
}
onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => {
if (!showOnlyMyCharts) {
this.slicesRequest = this.props.fetchSlices(
undefined,
this.state.searchTerm,
this.state.sortBy,
);
}
this.setState(prevState => ({
showOnlyMyCharts,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
prevState.sortBy,
showOnlyMyCharts,
this.props.userId,
userId,
),
}));
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);
};
[slices, searchTerm, sortBy, showOnlyMyCharts, userId],
);
render() {
const { theme } = this.props;
return (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
button > span > :first-of-type {
margin-right: 0;
const userIdForFetch = useCallback(
() => (showOnlyMyCharts ? userId : undefined),
[showOnlyMyCharts, userId],
);
// Refs so the debounced search reads the latest sortBy/userIdForFetch at
// fire time without recreating the debounce (which would drop a pending,
// armed-but-not-yet-fired search when sortBy/showOnlyMyCharts change).
const sortByRef = useRef(sortBy);
const userIdForFetchRef = useRef(userIdForFetch);
useEffect(() => {
sortByRef.current = sortBy;
}, [sortBy]);
useEffect(() => {
userIdForFetchRef.current = userIdForFetch;
}, [userIdForFetch]);
// componentDidMount
useEffect(() => {
slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update selectedSliceIdsSet when selectedSliceIds prop changes
useEffect(() => {
setSelectedSliceIdsSet(new Set(selectedSliceIds));
}, [selectedSliceIds]);
// componentWillUnmount
useEffect(
() => () => {
// Clears the redux store keeping only selected items
// Use refs to get latest values on unmount
const selectedSlices = pickBy(latestSlicesRef.current, (value: Slice) =>
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
);
updateSlices(selectedSlices);
if (slicesRequestRef.current instanceof AbortController) {
slicesRequestRef.current.abort();
}
},
[updateSlices],
);
const searchUpdated = useCallback((term: string) => {
setSearchTerm(term);
}, []);
const fetchSlicesRef = useRef(fetchSlices);
useEffect(() => {
fetchSlicesRef.current = fetchSlices;
}, [fetchSlices]);
// Create the debounce once (stable identity) so a pending search isn't
// dropped when sortBy/userIdForFetch change mid-typing. The debounced
// function reads the latest values from refs at fire time.
const handleChange = useMemo(
() =>
debounce((value: string) => {
searchUpdated(value);
slicesRequestRef.current = fetchSlicesRef.current(
userIdForFetchRef.current(),
value,
sortByRef.current,
);
}, 300),
[searchUpdated],
);
useEffect(
() => () => {
handleChange.cancel();
},
[handleChange],
);
const handleSelect = useCallback(
(newSortBy: keyof Slice) => {
setSortBy(newSortBy);
slicesRequestRef.current = fetchSlices(
userIdForFetch(),
searchTerm,
newSortBy,
);
},
[fetchSlices, searchTerm, userIdForFetch],
);
const onShowOnlyMyCharts = useCallback(
(checked: boolean) => {
if (!checked) {
slicesRequestRef.current = fetchSlices(undefined, searchTerm, sortBy);
}
setShowOnlyMyCharts(checked);
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, checked);
},
[fetchSlices, searchTerm, sortBy],
);
const rowRenderer = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
},
[filteredSlices, selectedSliceIdsSet, editMode],
);
return (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
button > span > :first-of-type {
margin-right: 0;
}
`}
>
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
}
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
showOnlyMyCharts ? t('Filter your charts') : t('Filter charts')
}
className="search-input"
onChange={ev => handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={sortBy}
onChange={handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<div
css={themeObj => css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: ${themeObj.sizeUnit}px;
padding: 0 ${themeObj.sizeUnit * 3}px ${themeObj.sizeUnit * 4}px
${themeObj.sizeUnit * 3}px;
`}
>
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
}
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
this.state.showOnlyMyCharts
? t('Filter your charts')
: t('Filter charts')
}
className="search-input"
onChange={ev => this.handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={this.state.sortBy}
onChange={this.handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<Checkbox
onChange={e => onShowOnlyMyCharts(e.target.checked)}
checked={showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
</div>
{isLoading && <Loading />}
{!isLoading && filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => filteredSlices[index].slice_id}
>
{rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{errorMessage && (
<div
css={theme => css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: ${theme.sizeUnit}px;
padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px
${theme.sizeUnit * 3}px;
css={css`
padding: 16px;
`}
>
<Checkbox
onChange={e => this.onShowOnlyMyCharts(e.target.checked)}
checked={this.state.showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
{errorMessage}
</div>
{this.props.isLoading && <Loading />}
{!this.props.isLoading && this.state.filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={this.state.filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => this.state.filteredSlices[index].slice_id}
>
{this.rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{this.props.errorMessage && (
<div
css={css`
padding: 16px;
`}
>
{this.props.errorMessage}
</div>
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={this.state.filteredSlices} />
</div>
);
}
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={filteredSlices} />
</div>
);
}
export default withTheme(SliceAdder);
export default SliceAdder;
@@ -43,6 +43,40 @@ test('triggers onRedo', () => {
expect(onRedo).toHaveBeenCalledTimes(1);
});
test('triggers onRedo with Ctrl+Shift+Z', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
fireEvent.keyDown(document.body, {
key: 'z',
keyCode: 90,
ctrlKey: true,
shiftKey: true,
});
expect(onRedo).toHaveBeenCalledTimes(1);
expect(onUndo).not.toHaveBeenCalled();
});
test('triggers onUndo via keyCode fallback for non-Latin layouts', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
// event.key is a non-'z' glyph (e.g. non-Latin layout), but code is KeyZ
fireEvent.keyDown(document.body, { key: 'я', code: 'KeyZ', ctrlKey: true });
expect(onUndo).toHaveBeenCalledTimes(1);
expect(onRedo).not.toHaveBeenCalled();
});
test('triggers onRedo via keyCode fallback for non-Latin layouts', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
// event.key is a non-'y' glyph, but code is KeyY
fireEvent.keyDown(document.body, { key: 'н', code: 'KeyY', ctrlKey: true });
expect(onRedo).toHaveBeenCalledTimes(1);
expect(onUndo).not.toHaveBeenCalled();
});
test('does not trigger when it is another key', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, useEffect } from 'react';
import { HeaderProps } from '../Header/types';
type UndoRedoKeyListenersProps = {
@@ -24,43 +24,43 @@ type UndoRedoKeyListenersProps = {
onRedo: HeaderProps['onRedo'];
};
class UndoRedoKeyListeners extends PureComponent<UndoRedoKeyListenersProps> {
constructor(props: UndoRedoKeyListenersProps) {
super(props);
this.handleKeydown = this.handleKeydown.bind(this);
}
function UndoRedoKeyListeners({ onUndo, onRedo }: UndoRedoKeyListenersProps) {
const handleKeydown = useCallback(
(event: KeyboardEvent) => {
const controlOrCommand = event.ctrlKey || event.metaKey;
if (controlOrCommand) {
const key = event.key.toLowerCase();
// Fall back to event.code (the physical key) so undo/redo still work on
// non-Latin keyboard layouts where event.key is a different glyph.
const isZ = key === 'z' || event.code === 'KeyZ';
const isY = key === 'y' || event.code === 'KeyY';
const isUndo = isZ && !event.shiftKey;
const isRedo = isY || (isZ && event.shiftKey);
const isEditingMarkdown = document?.querySelector(
'.dashboard-markdown--editing',
);
const isEditingTitle = document?.querySelector(
'.editable-title--editing',
);
componentDidMount() {
document.addEventListener('keydown', this.handleKeydown);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.handleKeydown);
}
handleKeydown(event: KeyboardEvent) {
const controlOrCommand = event.ctrlKey || event.metaKey;
if (controlOrCommand) {
const isZChar = event.key === 'z' || event.keyCode === 90;
const isYChar = event.key === 'y' || event.keyCode === 89;
const isEditingMarkdown = document?.querySelector(
'.dashboard-markdown--editing',
);
const isEditingTitle = document?.querySelector(
'.editable-title--editing',
);
if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) {
event.preventDefault();
const func = isZChar ? this.props.onUndo : this.props.onRedo;
func();
if (!isEditingMarkdown && !isEditingTitle && (isUndo || isRedo)) {
event.preventDefault();
const func = isUndo ? onUndo : onRedo;
func();
}
}
}
}
},
[onUndo, onRedo],
);
render() {
return null;
}
useEffect(() => {
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleKeydown]);
return null;
}
export default UndoRedoKeyListeners;
@@ -33,7 +33,6 @@ import {
} from 'react-dnd';
import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme';
import { dragConfig, dropConfig } from './dragDroppableConfig';
import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig';
import { DROP_FORBIDDEN } from '../../util/getDropPosition';
@@ -122,15 +121,22 @@ const DragDroppableStyles = styled.div`
}
`};
`;
/**
* Note: This component remains a class component because it is tightly integrated
* with react-dnd's class-based HOC system (DragSource/DropTarget). The HOCs
* access component instance properties directly (mounted, ref, props, setState)
* in the hover/drop callbacks defined in dragDroppableConfig.ts.
*
* Converting to a function component would require migrating to react-dnd's
* hooks API (useDrag/useDrop), which would be a more extensive refactor.
*/
// export unwrapped component for testing
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- react-dnd class-based HOC requires class component instance properties
export class UnwrappedDragDroppable extends PureComponent<
DragDroppableAllProps,
DragDroppableState
> {
mounted: boolean;
ref: HTMLDivElement | null;
static defaultProps = {
className: null,
style: null,
@@ -152,6 +158,10 @@ export class UnwrappedDragDroppable extends PureComponent<
dragPreviewRef() {},
};
mounted: boolean;
ref: HTMLDivElement | null;
constructor(props: DragDroppableAllProps) {
super(props);
this.state = {
@@ -283,7 +293,6 @@ export class UnwrappedDragDroppable extends PureComponent<
// react-dnd's DragSource/DropTarget HOC types don't play well with
// class components using spread config tuples, so we use type assertions here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DragDroppableAsAny =
UnwrappedDragDroppable as unknown as ReactComponentType<
Record<string, unknown>
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef, PureComponent } from 'react';
import { useRef, useCallback } from 'react';
import { styled } from '@apache-superset/core/theme';
import {
ModalTrigger,
@@ -33,39 +33,29 @@ const FilterScopeModalBody = styled.div(({ theme: { sizeUnit } }) => ({
paddingBottom: sizeUnit * 3,
}));
export default class FilterScopeModal extends PureComponent<
FilterScopeModalProps,
{}
> {
modal: ModalTriggerRef;
export default function FilterScopeModal({
triggerNode,
}: FilterScopeModalProps) {
const modalRef = useRef<ModalTriggerRef['current']>(null);
constructor(props: FilterScopeModalProps) {
super(props);
const handleCloseModal = useCallback((): void => {
modalRef.current?.close?.();
}, []);
this.modal = createRef() as ModalTriggerRef;
this.handleCloseModal = this.handleCloseModal.bind(this);
}
const filterScopeProps = {
onCloseModal: handleCloseModal,
};
handleCloseModal(): void {
this?.modal?.current?.close?.();
}
render() {
const filterScopeProps = {
onCloseModal: this.handleCloseModal,
};
return (
<ModalTrigger
ref={this.modal}
triggerNode={this.props.triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
}
return (
<ModalTrigger
ref={modalRef}
triggerNode={triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
}
@@ -0,0 +1,265 @@
/**
* 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 {
cleanup,
render,
screen,
userEvent,
} from 'spec/helpers/testing-library';
import FilterScopeSelector from './FilterScopeSelector';
import type { DashboardLayout } from 'src/dashboard/types';
// --- Mock child components ---
jest.mock('./FilterFieldTree', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => (
<div data-test="filter-field-tree">
FilterFieldTree (checked={String(props.checked)})
</div>
),
}));
jest.mock('./FilterScopeTree', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => (
<div data-test="filter-scope-tree">
FilterScopeTree (checked={String(props.checked)})
</div>
),
}));
// --- Mock utility functions ---
jest.mock('src/dashboard/util/getFilterFieldNodesTree', () => ({
__esModule: true,
default: jest.fn(() => [
{
value: 'ALL_FILTERS_ROOT',
label: 'All filters',
children: [
{
value: 1,
label: 'Filter A',
children: [
{ value: '1_column_b', label: 'Filter B' },
{ value: '1_column_c', label: 'Filter C' },
],
},
],
},
]),
}));
jest.mock('src/dashboard/util/getFilterScopeNodesTree', () => ({
__esModule: true,
default: jest.fn(() => [
{
value: 'ROOT_ID',
label: 'All charts',
children: [{ value: 2, label: 'Chart A' }],
},
]),
}));
jest.mock('src/dashboard/util/getFilterScopeParentNodes', () => ({
__esModule: true,
default: jest.fn(() => ['ROOT_ID']),
}));
jest.mock('src/dashboard/util/buildFilterScopeTreeEntry', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('src/dashboard/util/getKeyForFilterScopeTree', () => ({
__esModule: true,
default: jest.fn(() => '1_column_b'),
}));
jest.mock('src/dashboard/util/getSelectedChartIdForFilterScopeTree', () => ({
__esModule: true,
default: jest.fn(() => 1),
}));
jest.mock('src/dashboard/util/getFilterScopeFromNodesTree', () => ({
__esModule: true,
default: jest.fn(() => ({ scope: ['ROOT_ID'], immune: [] })),
}));
jest.mock('src/dashboard/util/getRevertedFilterScope', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
getChartIdsInFilterScope: jest.fn(() => [2, 3]),
}));
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
const mockDashboardFilters = {
1: {
chartId: 1,
componentId: 'component-1',
filterName: 'Filter A',
datasourceId: 'ds-1',
directPathToFilter: ['ROOT_ID', 'GRID', 'CHART_1'],
isDateFilter: false,
isInstantFilter: false,
columns: { column_b: undefined, column_c: undefined },
labels: { column_b: 'Filter B', column_c: 'Filter C' },
scopes: {
column_b: { immune: [], scope: ['ROOT_ID'] },
column_c: { immune: [], scope: ['ROOT_ID'] },
},
},
};
const mockLayout: DashboardLayout = {
ROOT_ID: { children: ['GRID'], id: 'ROOT_ID', type: 'ROOT' },
GRID: {
children: ['CHART_1', 'CHART_2'],
id: 'GRID',
type: 'GRID',
parents: ['ROOT_ID'],
},
CHART_1: {
meta: { chartId: 1, sliceName: 'Chart 1' },
children: [],
id: 'CHART_1',
type: 'CHART',
parents: ['ROOT_ID', 'GRID'],
},
CHART_2: {
meta: { chartId: 2, sliceName: 'Chart 2' },
children: [],
id: 'CHART_2',
type: 'CHART',
parents: ['ROOT_ID', 'GRID'],
},
} as unknown as DashboardLayout;
const defaultProps = {
dashboardFilters: mockDashboardFilters,
layout: mockLayout,
updateDashboardFiltersScope: jest.fn(),
setUnsavedChanges: jest.fn(),
onCloseModal: jest.fn(),
};
test('renders the header, filter field panel, and scope panel', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByText('Configure filter scopes')).toBeInTheDocument();
expect(screen.getByTestId('filter-field-tree')).toBeInTheDocument();
expect(screen.getByTestId('filter-scope-tree')).toBeInTheDocument();
});
test('renders the search input with correct placeholder', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute('type', 'text');
});
test('renders Close and Save buttons when filters exist', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
test('renders only Close button and a warning when no filters exist', () => {
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
useRedux: true,
});
expect(
screen.getByText('There are no filters in this dashboard.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Save' }),
).not.toBeInTheDocument();
});
test('does not render FilterFieldTree or FilterScopeTree when no filters exist', () => {
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
useRedux: true,
});
expect(screen.queryByTestId('filter-field-tree')).not.toBeInTheDocument();
expect(screen.queryByTestId('filter-scope-tree')).not.toBeInTheDocument();
});
test('calls onCloseModal when Close button is clicked', () => {
const onCloseModal = jest.fn();
render(
<FilterScopeSelector {...defaultProps} onCloseModal={onCloseModal} />,
{ useRedux: true },
);
userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onCloseModal).toHaveBeenCalledTimes(1);
});
test('calls updateDashboardFiltersScope, setUnsavedChanges, and onCloseModal when Save is clicked', () => {
const updateDashboardFiltersScope = jest.fn();
const setUnsavedChanges = jest.fn();
const onCloseModal = jest.fn();
render(
<FilterScopeSelector
{...defaultProps}
updateDashboardFiltersScope={updateDashboardFiltersScope}
setUnsavedChanges={setUnsavedChanges}
onCloseModal={onCloseModal}
/>,
{ useRedux: true },
);
userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(updateDashboardFiltersScope).toHaveBeenCalledTimes(1);
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
expect(onCloseModal).toHaveBeenCalledTimes(1);
});
test('renders the editing filters name section with "Editing 1 filter:" label', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByText('Editing 1 filter:')).toBeInTheDocument();
// The active filter label should appear (column_b maps to "Filter B")
expect(screen.getByText('Filter B')).toBeInTheDocument();
});
test('updates search text when typing in the search input', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
const searchInput = screen.getByPlaceholderText('Search...');
userEvent.type(searchInput, 'Chart');
expect(searchInput).toHaveValue('Chart');
});
@@ -16,12 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, ChangeEvent, type ReactElement } from 'react';
import {
useState,
useCallback,
useMemo,
ChangeEvent,
type ReactElement,
} from 'react';
import cx from 'classnames';
import { Button, Input } from '@superset-ui/core/components';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry';
import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree';
import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree';
@@ -90,30 +95,6 @@ export interface FilterScopeSelectorProps {
onCloseModal: () => void;
}
interface FilterScopeSelectorStateWithSelector {
showSelector: true;
activeFilterField: string | null;
searchText: string;
filterScopeMap: FilterScopeMap;
filterFieldNodes: FilterFieldNode[];
checkedFilterFields: string[];
expandedFilterIds: (string | number)[];
}
interface FilterScopeSelectorStateWithoutSelector {
showSelector: false;
activeFilterField?: undefined;
searchText?: undefined;
filterScopeMap?: undefined;
filterFieldNodes?: undefined;
checkedFilterFields?: undefined;
expandedFilterIds?: undefined;
}
type FilterScopeSelectorState =
| FilterScopeSelectorStateWithSelector
| FilterScopeSelectorStateWithoutSelector;
const ScopeContainer = styled.div`
${({ theme }) => css`
display: flex;
@@ -389,271 +370,358 @@ const ActionsContainer = styled.div`
`}
`;
export default class FilterScopeSelector extends PureComponent<
FilterScopeSelectorProps,
FilterScopeSelectorState
> {
allfilterFields: string[];
function initializeState(
dashboardFilters: Record<number, DashboardFilter>,
layout: DashboardLayout,
) {
if (Object.keys(dashboardFilters).length === 0) {
return {
showSelector: false as const,
allFilterFields: [] as string[],
defaultFilterKey: '',
};
}
defaultFilterKey: string;
// display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children ?? [];
const allFilterFields: string[] = [];
filtersNodes.forEach(({ children }) => {
(children ?? []).forEach(child => {
allFilterFields.push(String(child.value));
});
});
const defaultFilterKey = String(filtersNodes[0]?.children?.[0]?.value ?? '');
constructor(props: FilterScopeSelectorProps) {
super(props);
this.allfilterFields = [];
this.defaultFilterKey = '';
const { dashboardFilters, layout } = props;
if (Object.keys(dashboardFilters).length > 0) {
// display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children ?? [];
this.allfilterFields = [];
filtersNodes.forEach(({ children }) => {
(children ?? []).forEach(child => {
this.allfilterFields.push(String(child.value));
// build FilterScopeTree object for each filterKey
const filterScopeMap: FilterScopeMap = Object.values(
dashboardFilters,
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(columns).reduce<FilterScopeMap>(
(mapByChartId, columnName) => {
const filterKey = getDashboardFilterKey({
chartId: String(filterId),
column: columnName,
});
});
this.defaultFilterKey = String(
filtersNodes[0]?.children?.[0]?.value ?? '',
);
// build FilterScopeTree object for each filterKey
const filterScopeMap: FilterScopeMap = Object.values(
dashboardFilters,
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(
columns,
).reduce<FilterScopeMap>((mapByChartId, columnName) => {
const filterKey = getDashboardFilterKey({
chartId: String(filterId),
column: columnName,
});
const nodes = getFilterScopeNodesTree({
components: layout,
filterFields: [filterKey],
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
const chartIdsInFilterScope = (
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter((id: number) => id !== filterId);
return {
...mapByChartId,
[filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
};
}, {});
const nodes = getFilterScopeNodesTree({
components: layout,
filterFields: [filterKey],
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
const chartIdsInFilterScope = (
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter((id: number) => id !== filterId);
return {
...map,
...filterScopeByChartId,
...mapByChartId,
[filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
};
}, {});
// initial state: active defaultFilerKey
const { chartId } = getChartIdAndColumnFromFilterKey(
this.defaultFilterKey,
);
const checkedFilterFields: string[] = [];
const activeFilterField = this.defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds: (string | number)[] = [
ALL_FILTERS_ROOT,
chartId,
];
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField,
filterScopeMap,
layout,
});
this.state = {
showSelector: true,
activeFilterField,
searchText: '',
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
};
} else {
this.state = {
showSelector: false,
};
}
this.filterNodes = this.filterNodes.bind(this);
this.onChangeFilterField = this.onChangeFilterField.bind(this);
this.onCheckFilterScope = this.onCheckFilterScope.bind(this);
this.onExpandFilterScope = this.onExpandFilterScope.bind(this);
this.onSearchInputChange = this.onSearchInputChange.bind(this);
this.onCheckFilterField = this.onCheckFilterField.bind(this);
this.onExpandFilterField = this.onExpandFilterField.bind(this);
this.onClose = this.onClose.bind(this);
this.onSave = this.onSave.bind(this);
}
onCheckFilterScope(checked: (string | number)[] = []): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, filterScopeMap, checkedFilterFields } = state;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedEntry = {
...filterScopeMap[key],
checked,
};
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
filterScopeMap,
});
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
...updatedFilterScopeMap,
[key]: updatedEntry,
} as FilterScopeMap,
}));
}
onExpandFilterScope(expanded: string[] = []): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = state;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
expanded,
};
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
}));
}
{},
);
onCheckFilterField(checkedFilterFields: string[] = []): void {
const { layout } = this.props;
const state = this.state as FilterScopeSelectorStateWithSelector;
const { filterScopeMap } = state;
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
return {
...map,
...filterScopeByChartId,
};
}, {});
this.setState(() => ({
activeFilterField: null,
checkedFilterFields,
// initial state: active defaultFilerKey
const { chartId } = getChartIdAndColumnFromFilterKey(defaultFilterKey);
const checkedFilterFields: string[] = [];
const activeFilterField = defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds: (string | number)[] = [ALL_FILTERS_ROOT, chartId];
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField,
filterScopeMap,
layout,
});
return {
showSelector: true as const,
allFilterFields,
defaultFilterKey,
initialState: {
activeFilterField,
searchText: '',
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
},
}));
}
onExpandFilterField(expandedFilterIds: (string | number)[] = []): void {
this.setState(() => ({
expandedFilterIds,
}));
}
onChangeFilterField(filterField: { value?: string } = {}): void {
const { layout } = this.props;
const nextActiveFilterField = filterField.value;
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
activeFilterField: currentActiveFilterField,
} as FilterScopeMap,
filterFieldNodes,
checkedFilterFields,
filterScopeMap,
} = state;
expandedFilterIds,
},
};
}
// we allow single edit and multiple edit in the same view.
// if user click on the single filter field,
// will show filter scope for the single field.
// if user click on the same filter filed again,
// will toggle off the single filter field,
// and allow multi-edit all checked filter fields.
if (nextActiveFilterField === currentActiveFilterField) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
export default function FilterScopeSelector({
dashboardFilters,
layout,
updateDashboardFiltersScope,
setUnsavedChanges,
onCloseModal,
}: FilterScopeSelectorProps): ReactElement {
const initialized = useMemo(
() => initializeState(dashboardFilters, layout),
// Only initialize once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const { showSelector, allFilterFields } = initialized;
const [activeFilterField, setActiveFilterField] = useState<string | null>(
() =>
initialized.showSelector
? initialized.initialState.activeFilterField
: null,
);
const [searchText, setSearchText] = useState(() =>
initialized.showSelector ? initialized.initialState.searchText : '',
);
const [filterScopeMap, setFilterScopeMap] = useState<FilterScopeMap>(() =>
initialized.showSelector ? initialized.initialState.filterScopeMap : {},
);
const [filterFieldNodes] = useState<FilterFieldNode[]>(() =>
initialized.showSelector ? initialized.initialState.filterFieldNodes : [],
);
const [checkedFilterFields, setCheckedFilterFields] = useState<string[]>(
() =>
initialized.showSelector
? initialized.initialState.checkedFilterFields
: [],
);
const [expandedFilterIds, setExpandedFilterIds] = useState<
(string | number)[]
>(() =>
initialized.showSelector ? initialized.initialState.expandedFilterIds : [],
);
const filterNodes = useCallback(
(
filtered: FilterScopeTreeNode[] = [],
node: FilterScopeTreeNode = { value: '', label: '' },
currentSearchText: string,
): FilterScopeTreeNode[] => {
const filterNodesRecursive = (
f: FilterScopeTreeNode[],
n: FilterScopeTreeNode,
): FilterScopeTreeNode[] => filterNodes(f, n, currentSearchText);
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
filterNodesRecursive,
[],
);
if (
// Node's label matches the search string
node.label
.toLocaleLowerCase()
.indexOf((currentSearchText ?? '').toLocaleLowerCase()) > -1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
},
[],
);
const filterTree = useCallback(
(currentSearchText: string) => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
// Reset nodes back to unfiltered state
if (!currentSearchText) {
setFilterScopeMap(prev => ({
...prev,
[key]: {
...prev[key],
nodesFiltered: prev[key].nodes,
},
}));
} else {
setFilterScopeMap(prev => {
const nodesFiltered = prev[key].nodes.reduce<FilterScopeTreeNode[]>(
(filtered, node) => filterNodes(filtered, node, currentSearchText),
[],
);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
return {
...prev,
[key]: {
...prev[key],
nodesFiltered,
expanded,
},
};
});
}
},
[activeFilterField, checkedFilterFields, filterNodes],
);
const onCheckFilterScope = useCallback(
(checked: (string | number)[] = []): void => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
filterScopeMap,
});
setFilterScopeMap({
...filterScopeMap,
...updatedFilterScopeMap,
[key]: {
...filterScopeMap[key],
checked,
},
} as FilterScopeMap);
},
[activeFilterField, checkedFilterFields, filterScopeMap],
);
const onExpandFilterScope = useCallback(
(expanded: string[] = []): void => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
setFilterScopeMap(prev => ({
...prev,
[key]: {
...prev[key],
expanded,
},
}));
},
[activeFilterField, checkedFilterFields],
);
const onCheckFilterField = useCallback(
(newCheckedFilterFields: string[] = []): void => {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields: newCheckedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
this.setState({
activeFilterField: null,
filterScopeMap: {
setActiveFilterField(null);
setCheckedFilterFields(newCheckedFilterFields);
setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
});
},
[filterScopeMap, layout],
);
const onExpandFilterField = useCallback(
(newExpandedFilterIds: (string | number)[] = []): void => {
setExpandedFilterIds(newExpandedFilterIds);
},
[],
);
const onChangeFilterField = useCallback(
(filterField: { value?: string } = {}): void => {
const nextActiveFilterField = filterField.value;
// we allow single edit and multiple edit in the same view.
// if user click on the single filter field,
// will show filter scope for the single field.
// if user click on the same filter filed again,
// will toggle off the single filter field,
// and allow multi-edit all checked filter fields.
if (nextActiveFilterField === activeFilterField) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
setActiveFilterField(null);
setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
});
} else if (
nextActiveFilterField &&
this.allfilterFields.includes(nextActiveFilterField)
) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: nextActiveFilterField,
filterScopeMap,
layout,
});
});
} else if (
nextActiveFilterField &&
allFilterFields.includes(nextActiveFilterField)
) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: nextActiveFilterField,
filterScopeMap,
layout,
});
this.setState({
activeFilterField: nextActiveFilterField,
filterScopeMap: {
setActiveFilterField(nextActiveFilterField);
setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
});
}
}
});
}
},
[
activeFilterField,
allFilterFields,
checkedFilterFields,
filterScopeMap,
layout,
],
);
onSearchInputChange(e: ChangeEvent<HTMLInputElement>): void {
this.setState({ searchText: e.target.value }, this.filterTree);
}
const onSearchInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
const newSearchText = e.target.value;
setSearchText(newSearchText);
filterTree(newSearchText);
},
[filterTree],
);
onClose(): void {
this.props.onCloseModal();
}
const onClose = useCallback((): void => {
onCloseModal();
}, [onCloseModal]);
onSave(): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { filterScopeMap } = state;
const allFilterFieldScopes = this.allfilterFields.reduce<
const onSave = useCallback((): void => {
const allFilterFieldScopes = allFilterFields.reduce<
Record<string, ReturnType<typeof getFilterScopeFromNodesTree>>
>((map, filterKey) => {
const { nodes } = filterScopeMap[filterKey];
@@ -669,124 +737,32 @@ export default class FilterScopeSelector extends PureComponent<
};
}, {});
this.props.updateDashboardFiltersScope(allFilterFieldScopes);
this.props.setUnsavedChanges(true);
updateDashboardFiltersScope(allFilterFieldScopes);
setUnsavedChanges(true);
// click Save button will do save and close modal
this.props.onCloseModal();
}
onCloseModal();
}, [
allFilterFields,
filterScopeMap,
onCloseModal,
setUnsavedChanges,
updateDashboardFiltersScope,
]);
filterTree(): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
// Reset nodes back to unfiltered state
if (!state.searchText) {
this.setState(prevState => {
const prev = prevState as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered: filterScopeMap[key].nodes,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
});
} else {
const updater = (
prevState: FilterScopeSelectorState,
): FilterScopeSelectorState => {
const prev = prevState as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const nodesFiltered = filterScopeMap[key].nodes.reduce<
FilterScopeTreeNode[]
>(this.filterNodes, []);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered,
expanded,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
};
this.setState(updater);
}
}
filterNodes(
filtered: FilterScopeTreeNode[] = [],
node: FilterScopeTreeNode = { value: '', label: '' },
): FilterScopeTreeNode[] {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { searchText } = state;
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
this.filterNodes,
[],
);
if (
// Node's label matches the search string
node.label
.toLocaleLowerCase()
.indexOf((searchText ?? '').toLocaleLowerCase()) > -1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
}
renderFilterFieldList(): ReactElement | null {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
activeFilterField,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
} = state;
return (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={this.onChangeFilterField}
onCheck={this.onCheckFilterField}
onExpand={this.onExpandFilterField}
/>
);
}
renderFilterScopeTree(): ReactElement {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
filterScopeMap,
activeFilterField,
checkedFilterFields,
searchText,
} = state;
const renderFilterFieldList = (): ReactElement | null => (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={onChangeFilterField}
onCheck={onCheckFilterField}
onExpand={onExpandFilterField}
/>
);
const renderFilterScopeTree = (): ReactElement => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
@@ -803,26 +779,23 @@ export default class FilterScopeSelector extends PureComponent<
placeholder={t('Search...')}
type="text"
value={searchText}
onChange={this.onSearchInputChange}
onChange={onSearchInputChange}
/>
<FilterScopeTree
nodes={filterScopeMap[key].nodesFiltered}
checked={filterScopeMap[key].checked}
expanded={filterScopeMap[key].expanded}
onCheck={this.onCheckFilterScope}
onExpand={this.onExpandFilterScope}
onCheck={onCheckFilterScope}
onExpand={onExpandFilterScope}
// pass selectedFilterId prop to FilterScopeTree component,
// to hide checkbox for selected filter field itself
selectedChartId={selectedChartId}
/>
</>
);
}
};
renderEditingFiltersName(): ReactElement {
const { dashboardFilters } = this.props;
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields } = state;
const renderEditingFiltersName = (): ReactElement => {
const currentFilterLabels = ([] as string[])
.concat(activeFilterField || checkedFilterFields)
.filter(Boolean)
@@ -842,50 +815,42 @@ export default class FilterScopeSelector extends PureComponent<
</span>
</div>
);
}
};
render(): ReactElement {
const { showSelector } = this.state;
return (
<ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && renderEditingFiltersName()}
</ScopeHeader>
return (
<ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && this.renderEditingFiltersName()}
</ScopeHeader>
<ScopeBody className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
<ScopeBody className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
</div>
) : (
<ScopeSelector className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{renderFilterFieldList()}
</div>
) : (
<ScopeSelector className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{this.renderFilterFieldList()}
</div>
<div className="filter-scope-pane multi-edit-mode">
{this.renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
<div className="filter-scope-pane multi-edit-mode">
{renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
<ActionsContainer>
<Button buttonSize="small" onClick={this.onClose}>
{t('Close')}
<ActionsContainer>
<Button buttonSize="small" onClick={onClose}>
{t('Close')}
</Button>
{showSelector && (
<Button buttonSize="small" buttonStyle="primary" onClick={onSave}>
{t('Save')}
</Button>
{showSelector && (
<Button
buttonSize="small"
buttonStyle="primary"
onClick={this.onSave}
>
{t('Save')}
</Button>
)}
</ActionsContainer>
</ScopeContainer>
);
}
)}
</ActionsContainer>
</ScopeContainer>
);
}
@@ -128,6 +128,10 @@ const SliceContainer = styled.div`
const EMPTY_OBJECT: Record<string, never> = {};
// Stable no-op fallback for optional callbacks so we don't allocate a new
// function on every render (keeps referential equality for memoized children).
const NOOP = () => {};
// Helper function to get chart state with fallback
const getChartStateWithFallback = (
chartState: { state?: JsonObject } | undefined,
@@ -763,11 +767,11 @@ const Chart = (props: ChartProps) => {
},
slice.viz_type,
)}
queriesResponse={chart.queriesResponse ?? undefined}
queriesResponse={chart.queriesResponse ?? null}
timeout={timeout}
triggerQuery={chart.triggerQuery}
vizType={slice.viz_type}
setControlValue={props.setControlValue}
setControlValue={props.setControlValue ?? NOOP}
datasetsStatus={
datasetsStatus as 'loading' | 'error' | 'complete' | undefined
}
@@ -17,9 +17,8 @@
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, memo } from 'react';
import { css, styled } from '@apache-superset/core/theme';
import { Draggable } from '../../dnd/DragDroppable';
import HoverMenu from '../../menu/HoverMenu';
import DeleteComponentButton from '../../DeleteComponentButton';
@@ -63,50 +62,43 @@ const DividerLine = styled.div`
`}
`;
class Divider extends PureComponent<DividerProps> {
constructor(props: DividerProps) {
super(props);
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
}
handleDeleteComponent() {
const { deleteComponent, id, parentId } = this.props;
function Divider({
id,
parentId,
component,
depth,
parentComponent,
index,
editMode,
handleComponentDrop,
deleteComponent,
}: DividerProps) {
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
}
}, [deleteComponent, id, parentId]);
render() {
const {
component,
depth,
parentComponent,
index,
handleComponentDrop,
editMode,
} = this.props;
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<div ref={dragSourceRef}>
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
}
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<div ref={dragSourceRef}>
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
}
export default Divider;
export default memo(Divider);
@@ -16,10 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useState, useCallback, memo } from 'react';
import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme';
import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown';
import { EditableTitle } from '@superset-ui/core/components';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -85,10 +84,6 @@ interface HeaderProps {
updateComponents: (changes: Record<string, ComponentShape>) => void;
}
interface HeaderState {
isFocused: boolean;
}
const HeaderStyles = styled.div`
${({ theme }) => css`
font-weight: ${theme.fontWeightStrong};
@@ -159,149 +154,141 @@ const HeaderStyles = styled.div`
`}
`;
class Header extends PureComponent<HeaderProps, HeaderState> {
handleChangeSize: (nextValue: string) => void;
handleChangeBackground: (nextValue: string) => void;
handleChangeText: (nextValue: string) => void;
function Header({
id,
dashboardId,
parentId,
component,
depth,
parentComponent,
index,
editMode,
embeddedMode,
handleComponentDrop,
deleteComponent,
updateComponents,
}: HeaderProps) {
const [isFocused, setIsFocused] = useState(false);
constructor(props: HeaderProps) {
super(props);
this.state = {
isFocused: false,
};
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
const handleChangeFocus = useCallback((nextFocus: boolean): void => {
setIsFocused(nextFocus);
}, []);
this.handleChangeSize = (nextValue: string) =>
this.handleUpdateMeta('headerSize', nextValue);
this.handleChangeBackground = (nextValue: string) =>
this.handleUpdateMeta('background', nextValue);
this.handleChangeText = (nextValue: string) =>
this.handleUpdateMeta('text', nextValue);
}
handleChangeFocus(nextFocus: boolean): void {
this.setState(() => ({ isFocused: nextFocus }));
}
handleUpdateMeta(metaKey: keyof ComponentMeta, nextValue: string): void {
const { updateComponents, component } = this.props;
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
const handleUpdateMeta = useCallback(
(metaKey: keyof ComponentMeta, nextValue: string): void => {
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
},
},
},
} as Record<string, ComponentShape>);
}
}
} as Record<string, ComponentShape>);
}
},
[component, updateComponents],
);
handleDeleteComponent(): void {
const { deleteComponent, id, parentId } = this.props;
const handleChangeSize = useCallback(
(nextValue: string) => handleUpdateMeta('headerSize', nextValue),
[handleUpdateMeta],
);
const handleChangeBackground = useCallback(
(nextValue: string) => handleUpdateMeta('background', nextValue),
[handleUpdateMeta],
);
const handleChangeText = useCallback(
(nextValue: string) => handleUpdateMeta('text', nextValue),
[handleUpdateMeta],
);
const handleDeleteComponent = useCallback((): void => {
deleteComponent(id, parentId);
}
}, [deleteComponent, id, parentId]);
render() {
const { isFocused } = this.state;
const headerStyle = headerStyleOptions.find(
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
);
const {
dashboardId,
component,
depth,
parentComponent,
index,
handleComponentDrop,
editMode,
embeddedMode,
} = this.props;
const rowStyle = backgroundStyleOptions.find(
opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
);
const headerStyle = headerStyleOptions.find(
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
);
const rowStyle = backgroundStyleOptions.find(
opt =>
opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
);
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({
dragSourceRef,
}: {
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
}) => (
<div ref={dragSourceRef}>
{editMode &&
depth <= 2 && ( // drag handle looks bad when nested
<HoverMenu position="left">
<DragHandle position="left" />
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({
dragSourceRef,
}: {
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
}) => (
<div ref={dragSourceRef}>
{editMode &&
depth <= 2 && ( // drag handle looks bad when nested
<HoverMenu position="left">
<DragHandle position="left" />
</HoverMenu>
)}
<WithPopoverMenu
onChangeFocus={handleChangeFocus}
menuItems={[
<PopoverDropdown
id={`${component.id}-header-style`}
options={headerStyleOptions}
value={component.meta.headerSize as string}
onChange={handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
menuItems={[
<PopoverDropdown
id={`${component.id}-header-style`}
options={headerStyleOptions}
value={component.meta.headerSize as string}
onChange={this.handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={this.handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={this.handleChangeText}
showTooltip={false}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={handleChangeText}
showTooltip={false}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={Number(dashboardId)}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={Number(dashboardId)}
/>
)}
</HeaderStyles>
</WithPopoverMenu>
</div>
)}
</Draggable>
);
}
)}
</HeaderStyles>
</WithPopoverMenu>
</div>
)}
</Draggable>
);
}
export default Header;
export default memo(Header);
@@ -16,14 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ErrorInfo } from 'react';
import { connect } from 'react-redux';
import cx from 'classnames';
import type { JsonObject } from '@superset-ui/core';
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
import { ErrorBoundary } from 'src/components';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import { SafeMarkdown } from '@superset-ui/core/components';
import { EditorHost } from 'src/core/editors';
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
@@ -82,16 +84,6 @@ export interface MarkdownStateProps {
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
export interface MarkdownState {
isFocused: boolean;
markdownSource: string;
editor: EditorInstance | null;
editorMode: 'preview' | 'edit';
undoLength: number;
redoLength: number;
hasError?: boolean;
}
// TODO: localize
const MARKDOWN_PLACE_HOLDER = `# ✨Header 1
## Header 2
@@ -140,193 +132,199 @@ interface DragChildProps {
dragSourceRef: React.RefCallback<HTMLElement>;
}
class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
renderStartTime: number;
function Markdown({
id,
parentId,
component,
parentComponent,
index,
depth,
editMode,
availableColumnCount,
columnWidth,
onResizeStart,
onResize,
onResizeStop,
deleteComponent,
handleComponentDrop,
updateComponents,
logEvent,
addDangerToast,
undoLength,
redoLength,
htmlSanitization,
htmlSchemaOverrides,
}: MarkdownProps) {
const [isFocused, setIsFocused] = useState(false);
const [markdownSource, setMarkdownSource] = useState<string>(
component.meta.code as string,
);
const [editor, setEditorState] = useState<EditorInstance | null>(null);
const [editorMode, setEditorMode] = useState<'preview' | 'edit'>('preview');
const [hasError, setHasError] = useState(false);
constructor(props: MarkdownProps) {
super(props);
this.state = {
isFocused: false,
markdownSource: props.component.meta.code as string,
editor: null,
editorMode: 'preview',
undoLength: props.undoLength,
redoLength: props.redoLength,
};
this.renderStartTime = Logger.getTimestamp();
const renderStartTimeRef = useRef(Logger.getTimestamp());
const prevUndoLengthRef = useRef(undoLength);
const prevRedoLengthRef = useRef(redoLength);
const prevComponentWidthRef = useRef(component.meta.width);
const prevColumnWidthRef = useRef(columnWidth);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
this.handleMarkdownChange = this.handleMarkdownChange.bind(this);
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleResizeStart = this.handleResizeStart.bind(this);
this.setEditor = this.setEditor.bind(this);
this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this);
// getDerivedStateFromProps equivalent for undo/redo. Run during render
// (not in an effect) so the new markdownSource is applied before the commit,
// avoiding a one-frame flash of the old content. React bails out of the
// intermediate render without committing it.
const isUndoRedo =
undoLength !== prevUndoLengthRef.current ||
redoLength !== prevRedoLengthRef.current;
if (isUndoRedo) {
setMarkdownSource(component.meta.code as string);
setHasError(false);
prevUndoLengthRef.current = undoLength;
prevRedoLengthRef.current = redoLength;
}
componentDidMount(): void {
this.props.logEvent(LOG_ACTIONS_RENDER_CHART, {
viz_type: 'markdown',
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}
static getDerivedStateFromProps(
nextProps: MarkdownProps,
state: MarkdownState,
): MarkdownState | null {
const { hasError, editorMode, markdownSource, undoLength, redoLength } =
state;
const {
component: nextComponent,
undoLength: nextUndoLength,
redoLength: nextRedoLength,
} = nextProps;
// user click undo or redo ?
if (nextUndoLength !== undoLength || nextRedoLength !== redoLength) {
return {
...state,
undoLength: nextUndoLength,
redoLength: nextRedoLength,
markdownSource: nextComponent.meta.code as string,
hasError: false,
};
}
// Sync external code changes (not from undo/redo) while in preview mode.
useEffect(() => {
if (
!isUndoRedo &&
!hasError &&
editorMode === 'preview' &&
nextComponent.meta.code !== markdownSource
component.meta.code !== markdownSource
) {
return {
...state,
markdownSource: nextComponent.meta.code as string,
};
setMarkdownSource(component.meta.code as string);
}
}, [isUndoRedo, component.meta.code, hasError, editorMode, markdownSource]);
return state;
}
// componentDidMount equivalent: log render event
useEffect(() => {
logEvent(LOG_ACTIONS_RENDER_CHART, {
viz_type: 'markdown',
start_offset: renderStartTimeRef.current,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - renderStartTimeRef.current,
});
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
static getDerivedStateFromError(): { hasError: boolean } {
return {
hasError: true,
};
}
componentDidUpdate(prevProps: MarkdownProps): void {
// componentDidUpdate equivalent: resize editor when width changes
useEffect(() => {
if (
this.state.editor &&
(prevProps.component.meta.width !== this.props.component.meta.width ||
prevProps.columnWidth !== this.props.columnWidth)
editor &&
(prevComponentWidthRef.current !== component.meta.width ||
prevColumnWidthRef.current !== columnWidth)
) {
// Handle both Ace editor (resize method) and EditorHandle (no resize needed)
if (typeof this.state.editor.resize === 'function') {
this.state.editor.resize(true);
if (typeof editor.resize === 'function') {
editor.resize(true);
}
}
}
prevComponentWidthRef.current = component.meta.width;
prevColumnWidthRef.current = columnWidth;
}, [editor, component.meta.width, columnWidth]);
componentDidCatch(): void {
if (this.state.editor && this.state.editorMode === 'preview') {
this.props.addDangerToast(
t(
'This markdown component has an error. Please revert your recent changes.',
),
);
}
}
setEditor(editor: EditorInstance): void {
// EditorHandle or Ace editor instance
// For Ace: editor.getSession().setUseWrapMode(true)
// For EditorHandle: wrapEnabled is handled via options
if (editor?.getSession) {
editor.getSession!().setUseWrapMode(true);
}
this.setState({
editor,
});
}
handleChangeFocus(nextFocus: boolean | number): void {
const nextFocused = !!nextFocus;
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
this.setState(() => ({ isFocused: nextFocused }));
this.handleChangeEditorMode(nextEditMode);
}
handleChangeEditorMode(mode: 'edit' | 'preview'): void {
const nextState: MarkdownState = {
...this.state,
editorMode: mode,
};
if (mode === 'preview') {
this.updateMarkdownContent();
nextState.hasError = false;
}
this.setState(nextState);
}
updateMarkdownContent(): void {
const { updateComponents, component } = this.props;
if (component.meta.code !== this.state.markdownSource) {
const updateMarkdownContent = useCallback((): void => {
if (component.meta.code !== markdownSource) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
code: this.state.markdownSource,
code: markdownSource,
},
},
});
}
}
}, [component, markdownSource, updateComponents]);
handleMarkdownChange(nextValue: string): void {
this.setState({
markdownSource: nextValue,
});
}
handleDeleteComponent(): void {
const { deleteComponent, id, parentId } = this.props;
deleteComponent(id, parentId);
}
handleResizeStart(...args: Parameters<ResizeStartCallback>): void {
const { editorMode } = this.state;
const { editMode, onResizeStart } = this.props;
const isEditing = editorMode === 'edit';
onResizeStart(...args);
if (editMode && isEditing) {
this.updateMarkdownContent();
const setEditor = useCallback((editorInstance: EditorInstance): void => {
// EditorHandle or Ace editor instance
// For Ace: editor.getSession().setUseWrapMode(true)
// For EditorHandle: wrapEnabled is handled via options
if (editorInstance?.getSession) {
editorInstance.getSession!().setUseWrapMode(true);
}
}
setEditorState(editorInstance);
}, []);
shouldFocusMarkdown(
event: MouseEvent,
container: HTMLElement | null,
menuRef: HTMLElement | null,
): boolean {
if (container?.contains(event.target as Node)) return true;
if (menuRef?.contains(event.target as Node)) return true;
const handleChangeEditorMode = useCallback(
(mode: 'edit' | 'preview'): void => {
if (mode === 'preview') {
updateMarkdownContent();
setHasError(false);
}
setEditorMode(mode);
},
[updateMarkdownContent],
);
return false;
}
const handleChangeFocus = useCallback(
(nextFocus: boolean | number): void => {
const nextFocused = !!nextFocus;
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
setIsFocused(nextFocused);
handleChangeEditorMode(nextEditMode);
},
[handleChangeEditorMode],
);
renderEditMode(): JSX.Element {
return (
const handleMarkdownChange = useCallback((nextValue: string): void => {
setMarkdownSource(nextValue);
}, []);
const handleDeleteComponent = useCallback((): void => {
deleteComponent(id, parentId);
}, [deleteComponent, id, parentId]);
const handleResizeStart = useCallback(
(...args: Parameters<ResizeStartCallback>): void => {
const isEditing = editorMode === 'edit';
onResizeStart(...args);
if (editMode && isEditing) {
updateMarkdownContent();
}
},
[editorMode, editMode, onResizeStart, updateMarkdownContent],
);
const shouldFocusMarkdown = useCallback(
(
event: MouseEvent,
container: HTMLElement | null,
menuRef: HTMLElement | null,
): boolean => {
if (container?.contains(event.target as Node)) return true;
if (menuRef?.contains(event.target as Node)) return true;
return false;
},
[],
);
const handleRenderError = useCallback(
(_error: Error, _info: ErrorInfo): void => {
setHasError(true);
if (editorMode === 'preview') {
addDangerToast(
t(
'This markdown component has an error. Please revert your recent changes.',
),
);
}
},
[addDangerToast, editorMode],
);
const renderEditMode = useMemo(
() => (
<EditorHost
id={`markdown-editor-${this.props.id}`}
onChange={this.handleMarkdownChange}
id={`markdown-editor-${id}`}
onChange={handleMarkdownChange}
width="100%"
height="100%"
value={
// this allows "select all => delete" to give an empty editor
typeof this.state.markdownSource === 'string'
? this.state.markdownSource
typeof markdownSource === 'string'
? markdownSource
: MARKDOWN_PLACE_HOLDER
}
language="markdown"
@@ -336,126 +334,116 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
onReady={(handle: EditorInstance) => {
// The handle provides access to the underlying editor for resize
if (handle && typeof handle.focus === 'function') {
this.setEditor(handle);
setEditor(handle);
}
}}
data-test="editor"
/>
);
}
),
[id, markdownSource, handleMarkdownChange, setEditor],
);
renderPreviewMode(): JSX.Element {
const { hasError } = this.state;
return (
const renderPreviewMode = useMemo(
() => (
<SafeMarkdown
source={
hasError
? MARKDOWN_ERROR_MESSAGE
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER
: markdownSource || MARKDOWN_PLACE_HOLDER
}
htmlSanitization={this.props.htmlSanitization}
htmlSchemaOverrides={this.props.htmlSchemaOverrides}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
);
}
),
[hasError, markdownSource, htmlSanitization, htmlSchemaOverrides],
);
render() {
const { isFocused, editorMode } = this.state;
// inherit the size of parent columns
const widthMultiple =
parentComponent.type === COLUMN_TYPE
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
: component.meta.width || GRID_MIN_COLUMN_COUNT;
const {
component,
parentComponent,
index,
depth,
availableColumnCount,
columnWidth,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
} = this.props;
const isEditing = editorMode === 'edit';
// inherit the size of parent columns
const widthMultiple =
parentComponent.type === COLUMN_TYPE
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
: component.meta.width || GRID_MIN_COLUMN_COUNT;
const menuItems = useMemo(
() => [
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={editorMode}
onChange={handleChangeEditorMode}
/>,
],
[component.id, editorMode, handleChangeEditorMode],
);
const isEditing = editorMode === 'edit';
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
shouldFocus={this.shouldFocusMarkdown}
menuItems={[
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={this.state.editorMode}
onChange={this.handleChangeEditorMode}
/>,
]}
editMode={editMode}
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
<WithPopoverMenu
onChangeFocus={handleChangeFocus}
shouldFocus={shouldFocusMarkdown}
menuItems={menuItems}
editMode={editMode}
>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
id={component.id}
>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
>
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={this.handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
>
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<ErrorBoundary
key={hasError ? 'markdown-error' : 'markdown-ok'}
onError={handleRenderError}
showMessage={false}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
);
}
{editMode && isEditing ? renderEditMode : renderPreviewMode}
</ErrorBoundary>
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
);
}
interface ReduxState {
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { memo } from 'react';
import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme';
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -62,37 +62,37 @@ const NewComponentPlaceholder = styled.div`
`}
`;
export default class DraggableNewComponent extends PureComponent<DraggableNewComponentProps> {
static defaultProps = {
className: null,
IconComponent: undefined,
};
render() {
const { label, id, type, className, meta, IconComponent } = this.props;
return (
<DragDroppable
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={0}
depth={0}
editMode
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}
>
{IconComponent && <IconComponent iconSize="xl" />}
</NewComponentPlaceholder>
{label}
</NewComponent>
)}
</DragDroppable>
);
}
function DraggableNewComponent({
label,
id,
type,
className,
meta,
IconComponent,
}: DraggableNewComponentProps) {
return (
<DragDroppable
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={0}
depth={0}
editMode
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}
>
{IconComponent && <IconComponent iconSize="xl" />}
</NewComponentPlaceholder>
{label}
</NewComponent>
)}
</DragDroppable>
);
}
export default memo(DraggableNewComponent);
@@ -16,11 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import cx from 'classnames';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import PopoverDropdown, {
OptionProps,
@@ -90,18 +88,19 @@ function renderOption(option: OptionProps) {
);
}
export default class BackgroundStyleDropdown extends PureComponent<BackgroundStyleDropdownProps> {
render() {
const { id, value, onChange } = this.props;
return (
<PopoverDropdown
id={id}
options={backgroundStyleOptions}
value={value}
onChange={onChange}
renderButton={renderButton}
renderOption={renderOption}
/>
);
}
export default function BackgroundStyleDropdown({
id,
value,
onChange,
}: BackgroundStyleDropdownProps) {
return (
<PopoverDropdown
id={id}
options={backgroundStyleOptions}
value={value}
onChange={onChange}
renderButton={renderButton}
renderOption={renderOption}
/>
);
}
@@ -1,4 +1,3 @@
/* eslint-disable react/no-unused-state */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -17,15 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { RefObject, ReactNode, PureComponent } from 'react';
import { RefObject, ReactNode, useCallback, memo } from 'react';
import { styled } from '@apache-superset/core/theme';
import cx from 'classnames';
interface HoverMenuProps {
position: 'left' | 'top';
innerRef: RefObject<HTMLDivElement>;
children: ReactNode;
position?: 'left' | 'top';
innerRef?: RefObject<HTMLDivElement> | null;
children?: ReactNode;
onHover?: (data: { isHovered: boolean }) => void;
}
@@ -66,45 +65,41 @@ const HoverStyleOverrides = styled.div`
}
`;
export default class HoverMenu extends PureComponent<HoverMenuProps> {
static defaultProps = {
position: 'left',
innerRef: null,
children: null,
};
handleMouseEnter = () => {
const { onHover } = this.props;
function HoverMenu({
position = 'left',
innerRef = null,
children = null,
onHover,
}: HoverMenuProps) {
const handleMouseEnter = useCallback(() => {
if (onHover) {
onHover({ isHovered: true });
}
};
}, [onHover]);
handleMouseLeave = () => {
const { onHover } = this.props;
const handleMouseLeave = useCallback(() => {
if (onHover) {
onHover({ isHovered: false });
}
};
}, [onHover]);
render() {
const { innerRef, position, children } = this.props;
return (
<HoverStyleOverrides className="hover-menu-container">
<div
ref={innerRef}
className={cx(
'hover-menu',
position === 'left' && 'hover-menu--left',
position === 'top' && 'hover-menu--top',
)}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
data-test="hover-menu"
>
{children}
</div>
</HoverStyleOverrides>
);
}
return (
<HoverStyleOverrides className="hover-menu-container">
<div
ref={innerRef}
className={cx(
'hover-menu',
position === 'left' && 'hover-menu--left',
position === 'top' && 'hover-menu--top',
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
data-test="hover-menu"
>
{children}
</div>
</HoverStyleOverrides>
);
}
export default memo(HoverMenu);
@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { t } from '@apache-superset/core/translation';
import PopoverDropdown, {
OnChangeHandler,
} from '@superset-ui/core/components/PopoverDropdown';
@@ -40,18 +38,18 @@ const dropdownOptions = [
},
];
export default class MarkdownModeDropdown extends PureComponent<MarkdownModeDropdownProps> {
render() {
const { id, value, onChange } = this.props;
return (
<PopoverDropdown
data-test="markdown-mode-dropdown"
id={id}
options={dropdownOptions}
value={value}
onChange={onChange}
/>
);
}
export default function MarkdownModeDropdown({
id,
value,
onChange,
}: MarkdownModeDropdownProps) {
return (
<PopoverDropdown
data-test="markdown-mode-dropdown"
id={id}
options={dropdownOptions}
value={value}
onChange={onChange}
/>
);
}
@@ -106,7 +106,9 @@ test('should unfocus when another component is clicked', async () => {
<WithPopoverMenu
{...props}
editMode
shouldFocus={(event, container) => container?.contains(event.target)}
shouldFocus={(event, container, _menuRef) =>
container?.contains(event.target) ?? false
}
onChangeFocus={onChangeFocusA}
>
<div id="child-a" />
@@ -117,7 +119,9 @@ test('should unfocus when another component is clicked', async () => {
<WithPopoverMenu
{...props}
editMode
shouldFocus={(event, container) => container?.contains(event.target)}
shouldFocus={(event, container, _menuRef) =>
container?.contains(event.target) ?? false
}
onChangeFocus={onChangeFocusB}
>
<div id="child-b" />
@@ -16,7 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, CSSProperties, PureComponent } from 'react';
import {
ReactNode,
CSSProperties,
useCallback,
useEffect,
useRef,
useState,
memo,
} from 'react';
import cx from 'classnames';
import { addAlpha } from '@superset-ui/core';
import { css, styled } from '@apache-superset/core/theme';
@@ -26,26 +34,32 @@ type ShouldFocusContainer = HTMLDivElement & {
};
interface WithPopoverMenuProps {
children: ReactNode;
disableClick: boolean;
menuItems: ReactNode[];
onChangeFocus: (focus: boolean) => void;
isFocused: boolean;
// Event argument is left as "any" because of the clash. In defaultProps it seems
children?: ReactNode;
disableClick?: boolean;
menuItems?: ReactNode[];
onChangeFocus?: ((focus: boolean) => void) | null;
isFocused?: boolean;
// Event argument is left as "any" because of the clash. In props it seems
// like it should be React.FocusEvent<>, however from handleClick() we can also
// derive that type is EventListenerOrEventListenerObject.
shouldFocus: (
shouldFocus?: (
event: any,
container: ShouldFocusContainer,
container: ShouldFocusContainer | null,
menuRef: HTMLDivElement | null,
) => boolean;
editMode: boolean;
style: CSSProperties;
editMode?: boolean;
style?: CSSProperties | null;
}
interface WithPopoverMenuState {
isFocused: boolean;
}
const defaultShouldFocus = (
event: any,
container: ShouldFocusContainer | null,
menuRef: HTMLDivElement | null,
): boolean => {
if (container?.contains(event.target)) return true;
if (menuRef?.contains(event.target)) return true;
return false;
};
const WithPopoverMenuStyles = styled.div`
${({ theme }) => css`
@@ -104,151 +118,114 @@ const PopoverMenuStyles = styled.div`
`}
`;
export default class WithPopoverMenu extends PureComponent<
WithPopoverMenuProps,
WithPopoverMenuState
> {
container: ShouldFocusContainer;
function WithPopoverMenu({
children = null,
disableClick = false,
menuItems = [],
onChangeFocus = null,
isFocused: isFocusedProp = false,
shouldFocus: shouldFocusFunc = defaultShouldFocus,
editMode = false,
style = null,
}: WithPopoverMenuProps) {
const [isFocused, setIsFocused] = useState(isFocusedProp);
const containerRef = useRef<ShouldFocusContainer | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// Tracks the native event that just triggered focus via the container's
// onClick so the document-level listener (registered once focused) can
// skip it. Without this, the same click bubbles to document after a
// re-render has detached its event.target, causing shouldFocus to return
// false and immediately undoing the focus.
const focusEventRef = useRef<Event | null>(null);
menuRef: HTMLDivElement | null;
const handleClick = useCallback(
(event: any) => {
if (!editMode) {
return;
}
focusEvent: Event | null;
const nativeEvent = event.nativeEvent || event;
if (focusEventRef.current === nativeEvent) {
focusEventRef.current = null;
return;
}
static defaultProps = {
children: null,
disableClick: false,
onChangeFocus: null,
menuItems: [],
isFocused: false,
shouldFocus: (
event: any,
container: ShouldFocusContainer,
menuRef: HTMLDivElement | null,
) => {
if (container?.contains(event.target)) return true;
if (menuRef?.contains(event.target)) return true;
return false;
const shouldFocusResult = shouldFocusFunc(
event,
containerRef.current,
menuRef.current,
);
if (shouldFocusResult === isFocused) return;
if (!disableClick && shouldFocusResult && !isFocused) {
focusEventRef.current = nativeEvent;
setIsFocused(true);
if (onChangeFocus) onChangeFocus(true);
} else if (!shouldFocusResult && isFocused) {
setIsFocused(false);
if (onChangeFocus) onChangeFocus(false);
}
},
style: null,
};
[editMode, shouldFocusFunc, isFocused, disableClick, onChangeFocus],
);
constructor(props: WithPopoverMenuProps) {
super(props);
this.state = {
isFocused: props.isFocused!,
};
this.menuRef = null;
this.focusEvent = null;
this.setRef = this.setRef.bind(this);
this.setMenuRef = this.setMenuRef.bind(this);
this.handleClick = this.handleClick.bind(this);
}
// Keep the latest handleClick in a ref so the document listeners can be
// registered via a stable wrapper. This keeps the listener effect dependent
// only on focus/editMode transitions, instead of thrashing (remove + re-add)
// every time handleClick's identity changes.
const handleClickRef = useRef(handleClick);
useEffect(() => {
handleClickRef.current = handleClick;
}, [handleClick]);
componentDidUpdate(prevProps: WithPopoverMenuProps) {
if (this.props.editMode && this.props.isFocused && !this.state.isFocused) {
document.addEventListener('click', this.handleClick);
document.addEventListener('drag', this.handleClick);
this.setState({ isFocused: true });
} else if (this.state.isFocused && !this.props.editMode) {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
this.setState({ isFocused: false });
// Handle prop-driven focus changes and add/remove document listeners
useEffect(() => {
if (editMode && isFocusedProp && !isFocused) {
setIsFocused(true);
} else if (isFocused && !editMode) {
setIsFocused(false);
}
}
}, [editMode, isFocusedProp, isFocused]);
componentWillUnmount() {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
}
// Add/remove document event listeners only on focus/editMode transitions.
useEffect(() => {
if (isFocused && editMode) {
const listener = (event: Event) => handleClickRef.current(event);
document.addEventListener('click', listener);
document.addEventListener('drag', listener);
setRef(ref: ShouldFocusContainer) {
this.container = ref;
}
setMenuRef(ref: HTMLDivElement | null) {
this.menuRef = ref;
}
shouldHandleFocusChange(shouldFocus: boolean): boolean {
const { disableClick } = this.props;
const { isFocused } = this.state;
return (
(!disableClick && shouldFocus && !isFocused) ||
(!shouldFocus && isFocused)
);
}
handleClick(event: any) {
if (!this.props.editMode) {
return;
return () => {
document.removeEventListener('click', listener);
document.removeEventListener('drag', listener);
};
}
return undefined;
}, [isFocused, editMode]);
// Skip if this is the same event that just triggered focus via onClick.
// The document-level listener registered during focus will see the same
// event bubble up; by that time a re-render may have detached the
// original event.target, causing shouldFocus to return false and
// immediately undoing the focus.
const nativeEvent = event.nativeEvent || event;
if (this.focusEvent === nativeEvent) {
this.focusEvent = null;
return;
}
const {
onChangeFocus,
shouldFocus: shouldFocusFunc,
disableClick,
} = this.props;
const shouldFocus = shouldFocusFunc(event, this.container, this.menuRef);
if (shouldFocus === this.state.isFocused) return;
if (!disableClick && shouldFocus && !this.state.isFocused) {
document.addEventListener('click', this.handleClick);
document.addEventListener('drag', this.handleClick);
this.focusEvent = event.nativeEvent || event;
this.setState(() => ({ isFocused: true }));
if (onChangeFocus) onChangeFocus(true);
} else if (!shouldFocus && this.state.isFocused) {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
this.setState(() => ({ isFocused: false }));
if (onChangeFocus) onChangeFocus(false);
}
}
render() {
const { children, menuItems, editMode, style } = this.props;
const { isFocused } = this.state;
return (
<WithPopoverMenuStyles
ref={this.setRef}
onClick={this.handleClick}
role="none"
className={cx(
'with-popover-menu',
editMode && isFocused && 'with-popover-menu--focused',
)}
style={style}
>
{children}
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
<PopoverMenuStyles ref={this.setMenuRef}>
{menuItems.map((node: ReactNode, i: number) => (
<div className="menu-item" key={`menu-item-${i}`}>
{node}
</div>
))}
</PopoverMenuStyles>
)}
</WithPopoverMenuStyles>
);
}
return (
<WithPopoverMenuStyles
ref={containerRef}
onClick={handleClick}
role="none"
className={cx(
'with-popover-menu',
editMode && isFocused && 'with-popover-menu--focused',
)}
style={style ?? undefined}
>
{children}
{editMode && isFocused && menuItems?.some(Boolean) && (
<PopoverMenuStyles ref={menuRef}>
{menuItems.map((node: ReactNode, i: number) => (
<div className="menu-item" key={`menu-item-${i}`}>
{node}
</div>
))}
</PopoverMenuStyles>
)}
</WithPopoverMenuStyles>
);
}
export default memo(WithPopoverMenu);
@@ -1,148 +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 { ReactNode, useState, useCallback } from 'react';
import type { FormInstance } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { BaseModalBody, BaseForm, BaseModalWrapper } from './SharedStyles';
import { ModalFooter } from './ModalFooter';
export interface BaseConfigModalProps {
isOpen: boolean;
title: string;
expanded?: boolean;
onCancel: () => void;
onSave: () => void;
leftPane: ReactNode;
rightPane: ReactNode;
footer?: ReactNode;
form?: FormInstance;
onValuesChange?: (changedValues: any, allValues: any) => void;
canSave?: boolean;
saveAlertVisible?: boolean;
onDismissSaveAlert?: () => void;
onConfirmCancel?: () => void;
onToggleExpand?: () => void;
testId?: string;
maskClosable?: boolean;
destroyOnClose?: boolean;
centered?: boolean;
}
export const BaseConfigModal = ({
isOpen,
title,
expanded = false,
onCancel,
onSave,
leftPane,
rightPane,
footer,
form,
onValuesChange,
canSave = true,
saveAlertVisible = false,
onDismissSaveAlert,
onConfirmCancel,
onToggleExpand,
testId = 'base-config-modal',
maskClosable = false,
destroyOnClose = true,
centered = true,
}: BaseConfigModalProps) => {
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpandedControlled = onToggleExpand !== undefined;
const isExpanded = isExpandedControlled ? expanded : internalExpanded;
const handleToggleExpand = useCallback(() => {
if (isExpandedControlled && onToggleExpand) {
onToggleExpand();
} else {
setInternalExpanded(!internalExpanded);
}
}, [isExpandedControlled, onToggleExpand, internalExpanded]);
const handleCancel = useCallback(() => {
onCancel();
}, [onCancel]);
const handleSave = useCallback(() => {
onSave();
}, [onSave]);
const handleDismissSaveAlert = useCallback(() => {
if (onDismissSaveAlert) {
onDismissSaveAlert();
}
}, [onDismissSaveAlert]);
const handleConfirmCancel = useCallback(() => {
if (onConfirmCancel) {
onConfirmCancel();
} else {
onCancel();
}
}, [onConfirmCancel, onCancel]);
const defaultFooter = (
<ModalFooter
onCancel={handleCancel}
onSave={handleSave}
onConfirmCancel={handleConfirmCancel}
onDismiss={handleDismissSaveAlert}
saveAlertVisible={saveAlertVisible}
canSave={canSave}
expanded={isExpanded}
onToggleExpand={handleToggleExpand}
saveButtonTestId={`${testId}-save-button`}
cancelButtonTestId={`${testId}-cancel-button`}
/>
);
return (
<BaseModalWrapper
open={isOpen}
onCancel={handleCancel}
onOk={handleSave}
title={title}
footer={footer || defaultFooter}
centered={centered}
destroyOnClose={destroyOnClose}
maskClosable={maskClosable}
data-test={testId}
expanded={isExpanded}
>
<ErrorBoundary>
<BaseModalBody expanded={isExpanded}>
<BaseForm
form={form}
onValuesChange={onValuesChange}
layout="vertical"
css={{ width: '100%' }}
>
{leftPane}
{rightPane}
</BaseForm>
</BaseModalBody>
</ErrorBoundary>
</BaseModalWrapper>
);
};
export default BaseConfigModal;
@@ -0,0 +1,228 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { NativeFilterType } from '@superset-ui/core';
import type { Filter } from '@superset-ui/core';
import FilterValue from './FilterValue';
const mockGetChartDataRequest = jest.fn();
jest.mock('src/components/Chart/chartAction', () => ({
getChartDataRequest: (...args: unknown[]) => mockGetChartDataRequest(...args),
}));
jest.mock('src/middleware/asyncEvent', () => ({
waitForAsyncData: jest.fn(),
}));
jest.mock('@superset-ui/core', () => {
const original = jest.requireActual('@superset-ui/core');
return {
...original,
getChartMetadataRegistry: () => ({
get: () => ({ enableNoResults: false }),
}),
SuperChart: (props: Record<string, unknown>) => (
<div data-test="mock-super-chart" data-chart-type={props.chartType}>
SuperChart
</div>
),
isFeatureEnabled: () => false,
getClientErrorObject: (_err: unknown) =>
Promise.resolve({
message: 'Something went wrong',
errors: [
{ message: 'Test error', error_type: 'GENERIC_BACKEND_ERROR' },
],
}),
};
});
jest.mock('../useFilterOutlined', () => ({
useFilterOutlined: () => ({
outlinedFilterId: undefined,
lastUpdated: 0,
}),
}));
const mockUseFilterDependencies = jest.fn().mockReturnValue({});
const mockUseTransitiveParentIds = jest.fn().mockReturnValue([]);
jest.mock('./state', () => ({
useFilterDependencies: (...args: unknown[]) =>
mockUseFilterDependencies(...args),
useTransitiveParentIds: (...args: unknown[]) =>
mockUseTransitiveParentIds(...args),
}));
const mockStore = configureStore([thunk]);
const createMockFilter = (overrides: Partial<Filter> = {}): Filter => ({
id: 'NATIVE_FILTER-1',
name: 'Test Filter',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'country' } }],
defaultDataMask: {},
controlValues: {},
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
type: NativeFilterType.NativeFilter,
description: 'Test filter description',
...overrides,
});
const getDefaultStoreState = () => ({
dashboardInfo: { id: 1 },
dashboardState: {
isRefreshing: false,
isFiltersRefreshing: false,
directPathToChild: [],
directPathLastUpdated: 0,
},
nativeFilters: {
filters: {
'NATIVE_FILTER-1': createMockFilter(),
},
filterSets: {},
},
dataMask: {},
charts: {},
dashboardLayout: { present: {} },
});
const defaultProps = {
filter: createMockFilter(),
dataMaskSelected: {},
onFilterSelectionChange: jest.fn(),
inView: true,
};
function renderFilterValue(
propOverrides: Record<string, unknown> = {},
stateOverrides: Record<string, unknown> = {},
) {
const state = { ...getDefaultStoreState(), ...stateOverrides };
const store = mockStore(state);
const mergedProps = { ...defaultProps, ...propOverrides };
return render(
<Provider store={store}>
<FilterValue {...(mergedProps as typeof defaultProps)} />
</Provider>,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('renders loading spinner when filter has a data source', () => {
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
renderFilterValue();
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByTestId('mock-super-chart')).not.toBeInTheDocument();
});
test('renders SuperChart after data loads successfully', async () => {
mockGetChartDataRequest.mockResolvedValue({
response: { status: 200 },
json: { result: [{ data: [{ country: 'US' }] }] },
});
renderFilterValue();
await waitFor(() => {
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
});
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
test('renders error state when API call fails', async () => {
mockGetChartDataRequest.mockRejectedValue(
new Response(JSON.stringify({ message: 'Server Error' }), { status: 500 }),
);
renderFilterValue();
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
// No ErrorMessageComponent is registered for GENERIC_BACKEND_ERROR in the
// test environment, so FilterValue renders its fallback ErrorAlert.
expect(await screen.findByText('Network error')).toBeInTheDocument();
});
test('does not fetch data when filter has not been in view', () => {
renderFilterValue({ inView: false });
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
});
test('does not render loading spinner when filter has no data source', () => {
const filterWithoutDataSource = createMockFilter({
targets: [{ column: { name: 'country' } }],
});
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
renderFilterValue({ filter: filterWithoutDataSource });
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
});
test('skips data fetch when cascade parent filters have no values selected', () => {
// useFilterDependencies returns dependencies with a filter (from parent defaults),
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so
// the component skips the fetch.
mockUseFilterDependencies.mockReturnValue({
filters: [{ col: 'region', op: 'IN', val: ['US'] }],
});
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
const childFilter = createMockFilter({
id: 'NATIVE_FILTER-CHILD',
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
});
const stateWithParent = {
nativeFilters: {
filters: {
'NATIVE_FILTER-CHILD': childFilter,
'NATIVE_FILTER-PARENT': createMockFilter({
id: 'NATIVE_FILTER-PARENT',
}),
},
filterSets: {},
},
};
renderFilterValue(
{
filter: childFilter,
dataMaskSelected: {},
},
stateWithParent,
);
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
});
@@ -41,7 +41,8 @@ import {
getClientErrorObject,
isChartCustomization,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { styled, SupersetTheme } from '@apache-superset/core/theme';
import { useTheme } from '@emotion/react';
import { useDispatch, useSelector } from 'react-redux';
import { isEqual, isEqualWith } from 'lodash';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
@@ -141,6 +142,7 @@ const FilterValue: FC<FilterValueProps> = ({
clearAllTrigger,
onClearAllComplete,
}) => {
const theme = useTheme() as SupersetTheme;
const { id, targets, filterType } = filter;
const isCustomization = isChartCustomization(filter);
const allowedTimeGrains = isCustomization
@@ -487,6 +489,7 @@ const FilterValue: FC<FilterValueProps> = ({
enableNoResults={metadata?.enableNoResults}
isRefreshing={isRefreshing}
hooks={hooks}
theme={theme}
/>
)}
</StyledDiv>
@@ -299,7 +299,10 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
);
});
test('checkIsApplyDisabled handles filter count mismatch', () => {
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
// Regression: when a required filter's default isn't applied (Applied missing
// the entry) and the user types a value, Selected gains an entry Applied
// doesn't have. Apply must be enabled so the user can commit the value.
const dataMaskSelected: DataMaskStateWithId = {
'filter-1': {
id: 'filter-1',
@@ -322,7 +325,7 @@ test('checkIsApplyDisabled handles filter count mismatch', () => {
const filters = [createFilter('filter-1'), createFilter('filter-2')];
expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe(
true,
false,
);
});
@@ -74,13 +74,9 @@ export const checkIsApplyDisabled = (
const selectedExtraFormData = getOnlyExtraFormData(dataMaskSelected);
const appliedExtraFormData = getOnlyExtraFormData(dataMaskApplied);
// Check counts first
const selectedCount = Object.keys(selectedExtraFormData).length;
const appliedCount = Object.keys(appliedExtraFormData).length;
if (selectedCount !== appliedCount) return true;
// Check for changes
// Check for changes. ignoreUndefined drops empty keys on both sides so that
// a filter present in Selected with a real value but absent (or undefined)
// in Applied is correctly detected as a change.
const dataEqual = areObjectsEqual(
selectedExtraFormData,
appliedExtraFormData,
@@ -1,29 +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 { useSelector } from 'react-redux';
import { useMemo } from 'react';
import { RootState } from 'src/dashboard/types';
import getChartIdsFromLayout from '../getChartIdsFromLayout';
export const useAllChartIds = () => {
const layout = useSelector(
(state: RootState) => state.dashboardLayout.present,
);
return useMemo(() => getChartIdsFromLayout(layout), [layout]);
};
@@ -1,113 +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 { Layout, LayoutItem } from 'src/dashboard/types';
import { TAB_TYPE, DASHBOARD_GRID_TYPE } from '../componentTypes';
import { DASHBOARD_ROOT_ID } from '../constants';
import findNonTabChildChartIds from './findNonTabChildChartIds';
interface TopLevelNode {
id: string;
type: string;
parent_type: string | null;
parent_id: string | null;
index: number | null;
depth: number;
slice_ids: number[];
}
interface RecurseParams {
node: LayoutItem | undefined;
index?: number | null;
depth: number;
parentType?: string | null;
parentId?: string | null;
}
// This function traverses the layout to identify top grid + tab level components
// for which we track load times
function findTopLevelComponentIds(layout: Layout): TopLevelNode[] {
const topLevelNodes: TopLevelNode[] = [];
function recurseFromNode({
node,
index = null,
depth,
parentType = null,
parentId = null,
}: RecurseParams): void {
if (!node) return;
let nextParentType = parentType;
let nextParentId = parentId;
let nextDepth = depth;
if (node.type === TAB_TYPE || node.type === DASHBOARD_GRID_TYPE) {
const chartIds = findNonTabChildChartIds({
layout,
id: node.id,
});
topLevelNodes.push({
id: node.id,
type: node.type,
parent_type: parentType,
parent_id: parentId,
index,
depth,
slice_ids: chartIds,
});
nextParentId = node.id;
nextParentType = node.type;
nextDepth += 1;
}
if (node.children && node.children.length) {
node.children.forEach((childId, childIndex) => {
recurseFromNode({
node: layout[childId],
index: childIndex,
parentType: nextParentType,
parentId: nextParentId,
depth: nextDepth,
});
});
}
}
recurseFromNode({
node: layout[DASHBOARD_ROOT_ID],
depth: 0,
});
return topLevelNodes;
}
// This method is called frequently, so cache results
let cachedLayout: Layout | undefined;
let cachedTopLevelNodes: TopLevelNode[] = [];
export default function findTopLevelComponentIdsWithCache(
layout: Layout,
): TopLevelNode[] {
if (layout === cachedLayout) {
return cachedTopLevelNodes;
}
cachedLayout = layout;
cachedTopLevelNodes = findTopLevelComponentIds(layout);
return cachedTopLevelNodes;
}
@@ -1,59 +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 { ChartState } from 'src/explore/types';
import { Layout } from 'src/dashboard/types';
import findTopLevelComponentIds from './findTopLevelComponentIds';
import childChartsDidLoad from './childChartsDidLoad';
interface GetLoadStatsParams {
layout: Layout;
chartQueries: Record<string, Partial<ChartState>>;
}
interface LoadStats {
didLoad: boolean;
id: string;
minQueryStartTime: number | null;
[key: string]: unknown;
}
export default function getLoadStatsPerTopLevelComponent({
layout,
chartQueries,
}: GetLoadStatsParams): Record<string, LoadStats> {
const topLevelComponents = findTopLevelComponentIds(layout);
const stats: Record<string, LoadStats> = {};
topLevelComponents.forEach(topLevelComponent => {
const { id, ...restStats } = topLevelComponent;
const { didLoad, minQueryStartTime } = childChartsDidLoad({
id,
layout,
chartQueries,
});
stats[id] = {
didLoad,
id,
minQueryStartTime,
...restStats,
};
});
return stats;
}
@@ -42,7 +42,9 @@ const isUserDashboardOwner = (
user: UserWithPermissionsAndRoles | UndefinedUser,
) =>
isUserWithPermissionsAndRoles(user) &&
dashboard.owners.some(owner => owner.id === user.userId);
[...dashboard.owners, ...(dashboard.extra_owners ?? [])].some(
owner => owner.id === user.userId,
);
export const canUserEditDashboard = (
dashboard: Dashboard,
@@ -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.
*/
export const stopPeriodicRender = (refreshTimer?: number) => {
if (refreshTimer) {
clearInterval(refreshTimer);
}
};
interface SetPeriodicRunnerProps {
interval?: number;
periodicRender: TimerHandler;
refreshTimer?: number;
}
export default function setPeriodicRunner({
interval = 0,
periodicRender,
refreshTimer,
}: SetPeriodicRunnerProps) {
stopPeriodicRender(refreshTimer);
if (interval > 0) {
return setInterval(periodicRender, interval);
}
return 0;
}

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