Compare commits

...
Author SHA1 Message Date
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5106afb07f chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#39145)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:16:28 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2bd4131636 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39134)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:15:18 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7e452df1cc chore(deps): bump anthropics/claude-code-action from 1.0.87 to 1.0.89 (#39132)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:14:30 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a626d06415 chore(deps): bump caniuse-lite from 1.0.30001784 to 1.0.30001786 in /docs (#39128)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:11:13 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d159edc9a6 chore(deps-dev): bump @swc/core from 1.15.21 to 1.15.24 in /superset-frontend (#39127)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:10:17 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 96fa2cbd2b chore(deps): update @deck.gl/aggregation-layers requirement from ~9.2.9 to ~9.2.11 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#39126)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:09:49 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9750881193 chore(deps-dev): bump @types/node from 25.5.0 to 25.5.2 in /superset-websocket (#39125)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:09:25 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3db92021c7 chore(deps-dev): bump eslint from 10.1.0 to 10.2.0 in /superset-websocket (#39123)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:08:46 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5ccfc530b2 chore(deps): bump geolib from 3.3.4 to 3.3.14 in /superset-frontend (#39092)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:48:08 -07:00
Amin Ghadersohi 5f9fc31ae2 feat(mcp): add get_chart_type_schema tool for on-demand schema discovery (#39142) 2026-04-07 12:07:45 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8e811de564 chore(deps): bump hot-shots from 14.2.0 to 14.3.1 in /superset-websocket (#39147)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 22:43:50 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 027de6339b chore(deps-dev): bump jsdom from 29.0.1 to 29.0.2 in /superset-frontend (#39155)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 21:39:04 +07:00
Amin Ghadersohi bf9aff19b5 fix(mcp): compress chart config schemas to reduce search_tools token usage (#39018) 2026-04-06 19:52:03 -04:00
SBIN2010 b05764d070 feat: Add currencies controls in country map (#39016) 2026-04-06 23:20:03 +03:00
Amin Ghadersohi 7be2acb2f3 fix(mcp): add description and certification fields to default list tool columns (#39017) 2026-04-06 13:37:52 -04:00
Amin Ghadersohi 83ad1eca26 fix(mcp): add dynamic response truncation for oversized info tool responses (#39107) 2026-04-06 12:36:03 -04:00
Amin Ghadersohi 92747246fc fix(mcp): remove JWT ValueError g.user fallback in auth layer (#39106) 2026-04-06 12:35:46 -04:00
Amin Ghadersohi 7380a59ab8 fix(mcp): fix form_data null, dataset URL, ASCII preview, and chart rename (#39109) 2026-04-06 12:34:26 -04:00
Ville Brofeldt e56f8cc4fb fix(security_manager): custom auth_view issue (#39098) 2026-04-06 09:04:59 -07:00
Ville Brofeldt 7c79b9ab61 fix(migrations): check pre-existing foreign keys on create util (#39099) 2026-04-06 09:04:22 -07:00
a62be684a0 feat(mcp): add database connection listing and info tools (#39111)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-04-06 11:34:10 -04:00
Michael S. MolinaandClaude Sonnet 4.6 a3dfbd7bff fix(deps): revert simple-zstd from 2.1.0 back to 1.4.2 (#39139)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:28:28 -03:00
Sam Firke 12eb40db01 fix(SQL Lab): handle columns without names (#38986) 2026-04-06 10:09:16 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d796543f5a chore(deps): update @deck.gl/react requirement from ~9.2.9 to ~9.2.11 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#39033)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:07:42 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e5ae626433 chore(deps): bump dawidd6/action-download-artifact from 19 to 20 (#39081)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:06:06 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8195574345 chore(deps): bump anthropics/claude-code-action from 1.0.85 to 1.0.87 (#39083)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:04:43 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6b029997d9 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39087)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:04:04 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7a64483e6b chore(deps-dev): bump @swc/plugin-emotion from 14.7.0 to 14.8.0 in /superset-frontend (#39088)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:03:44 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e424b55036 chore(deps-dev): bump babel-loader from 10.1.0 to 10.1.1 in /superset-frontend (#39090)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:02:57 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 613e6d6cde chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#39093)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:02:11 -07:00
Amin Ghadersohi b3a402d936 fix(mcp): handle stale SSL connections, heatmap duplicate labels, and session rollback (#39015) 2026-04-03 16:07:29 -04:00
JUST.in DO IT c7d175b842 fix(dashboard): remove opacity on filter dropdown (#39074) 2026-04-03 09:31:23 -07:00
Amin Ghadersohi 851bbeea48 fix(mcp): improve execute_sql response-too-large error to suggest limit parameter (#39003) 2026-04-03 10:57:31 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c5bce756f0 chore(deps): bump yeoman-generator from 7.5.1 to 8.1.2 in /superset-frontend (#38671)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:13:20 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3239f058c8 chore(deps-dev): bump baseline-browser-mapping from 2.10.10 to 2.10.13 in /superset-frontend (#39044)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:10:35 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7e0c634c3a chore(deps-dev): bump typescript-eslint from 8.56.1 to 8.58.0 in /superset-websocket (#38997)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:46:19 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a9ced5c881 chore(deps): bump lodash-es from 4.17.23 to 4.18.1 in /superset-frontend (#39026)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:45:55 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ace5f9d8c2 chore(deps): bump lodash from 4.17.23 to 4.18.1 in /superset-frontend/cypress-base (#39027)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:45:28 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0452d1515a chore(deps-dev): bump @babel/preset-env from 7.29.0 to 7.29.2 in /superset-frontend (#39028)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:44:57 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0330fdeb00 chore(deps-dev): bump ts-jest from 29.4.6 to 29.4.9 in /superset-websocket (#39029)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:44:32 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f2ff24d811 chore(deps): bump @ant-design/icons from 5.6.1 to 6.1.1 in /superset-frontend (#39050)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:42:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c51132f824 chore(deps): bump aws-actions/amazon-ecr-login from 2.1.1 to 2.1.2 (#39042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:43:40 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b4cb815ebf chore(deps): bump anthropics/claude-code-action from 1.0.83 to 1.0.85 (#39037)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:43:05 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 08d1ddd9fb chore(deps-dev): bump mini-css-extract-plugin from 2.10.1 to 2.10.2 in /superset-frontend (#39054)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:33:50 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 23ac4cb3a4 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39051)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:32:55 +07:00
Joe LiandClaude Opus 4.6 5662ecab15 chore(tests): promote Playwright experimental tests to stable (#38924)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:00:06 -07:00
Joe LiandClaude Opus 4.6 9e27d682f6 test(alerts/reports): close backend and frontend test coverage gaps (#38591)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 11:55:24 -07:00
Joe Li f0fcdcc76a chore: package bumps (#39014) 2026-04-02 11:53:07 -07:00
Kamil Gabryjelski 135e0f8099 fix(mcp): Created dashboard should be in draft state by default (#39011) 2026-04-02 19:28:51 +02:00
GeidōandClaude Opus 4.6 25eea295f6 fix(reports): log exception traceback in _get_csv_data (#39069)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 18:37:57 +02:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
c372f5980c chore(deps): bump lodash from 4.17.23 to 4.18.1 in /superset-frontend (#39043)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-04-02 23:36:36 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3802acb1e0 chore(deps-dev): bump jest-html-reporter from 4.3.0 to 4.4.0 in /superset-frontend (#39053)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:08:48 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> bdb0030cf8 chore(deps-dev): bump @swc/core from 1.15.18 to 1.15.21 in /superset-frontend (#39045)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:07:44 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 87f0540acd chore(deps-dev): bump ts-jest from 29.4.6 to 29.4.9 in /superset-frontend (#39049)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:06:54 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 985d7b6a79 chore(deps-dev): bump @types/node from 25.3.5 to 25.5.0 in /superset-frontend (#39055)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:06:13 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 59f92f979a chore(deps-dev): bump eslint-plugin-testing-library from 7.16.1 to 7.16.2 in /superset-frontend (#39056)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:03:50 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5cc286e383 chore(deps-dev): bump @playwright/test from 1.58.2 to 1.59.1 in /superset-frontend (#39057)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:59:29 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
26f4a5acad chore(deps): bump azure/setup-helm from 4.3.1 to 5.0.0 (#38815)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-02 01:28:56 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fdd08d3b70 chore(deps): bump ws from 8.19.0 to 8.20.0 in /superset-websocket (#38994)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 01:28:52 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1aac6c9474 chore(deps): update @deck.gl/react requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38150)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 14:38:12 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Evan RusackasJoe Li
7acb0c6d05 chore(deps-dev): bump @types/node from 25.3.3 to 25.3.5 in /superset-frontend (#38510)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 14:27:54 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
00eb86d03f chore(deps-dev): bump @swc/plugin-emotion from 14.6.0 to 14.7.0 in /superset-frontend (#38333)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 14:27:11 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
1d0e836a29 chore(deps): update @deck.gl/extensions requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 13:30:26 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
ec6640b188 chore(deps-dev): update fs-extra requirement from ^11.3.3 to ^11.3.4 in /superset-frontend/packages/generator-superset (#38383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 13:29:47 -07:00
Rayan Salhab ff3b8d8398 fix(ace-editor): fix cursor misalignment in markdown editor (#38928) 2026-04-01 12:03:28 -07:00
Michael S. Molina 022342839a fix(echarts): fix stacked horizontal bar chart clipping and duplicate x-axis labels (#39012) 2026-04-01 15:50:08 -03:00
Michael S. MolinaClaude Opus 4.6codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
38f0dc74f7 fix(dataset-editor): improve modal layout and fix Settings tab horizontal scroll (#39009)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-04-01 15:36:17 -03:00
Amin Ghadersohi 0bae05d4a9 fix(mcp): handle table chart raw mode in query builders and sanitize dashboard titles (#38990) 2026-04-01 13:42:59 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1bb41a6e60 chore(deps): bump anthropics/claude-code-action from 1.0.82 to 1.0.83 (#39001)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 10:20:18 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4423134739 chore(deps): bump ioredis from 5.10.0 to 5.10.1 in /superset-websocket (#38993)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 10:19:26 -07:00
Amin Ghadersohi 190f1a59c5 fix(mcp): fix dashboard owners Pydantic crash and preserve chart preview filters (#38987) 2026-04-01 13:18:28 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5f99d613a0 chore(deps): bump markdown-to-jsx from 9.7.6 to 9.7.8 in /superset-frontend (#38507)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 09:34:47 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
6adc816805 chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#38438)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 09:34:01 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
aa97679327 chore(deps): update @deck.gl/aggregation-layers requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38148)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 09:32:38 -07:00
Michael S. MolinaandClaude Opus 4.6 94d8735d4b fix(query): pass datasource table to template processor for schema-aware Jinja rendering (#38984)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 13:08:12 -03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 64c8d652e1 chore(deps-dev): bump @types/node from 25.3.5 to 25.5.0 in /superset-websocket (#38995)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:48:45 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d30c5b4eee chore(deps): bump caniuse-lite from 1.0.30001782 to 1.0.30001784 in /docs (#38998)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:37:29 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8ed75787cb chore(deps-dev): bump jsdom from 28.1.0 to 29.0.1 in /superset-frontend (#38999)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 17:39:08 +07:00
michaelkremmel 4ee391e0d7 docs(db): Update crate.py with new Homepage URL (#39002) 2026-04-01 17:37:51 +07:00
132 changed files with 9115 additions and 3277 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # beta
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Login to Amazon ECR
if: steps.describe-services.outputs.active == 'true'
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Delete ECR image tag
if: steps.describe-services.outputs.active == 'true'
+2 -2
View File
@@ -199,7 +199,7 @@ jobs:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Load, tag and push image to ECR
id: push-image
@@ -235,7 +235,7 @@ jobs:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Check target image exists in ECR
id: check-image
+2 -2
View File
@@ -70,7 +70,7 @@ jobs:
yarn install --check-cache
- name: Download database diagnostics (if triggered by integration tests)
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
@@ -79,7 +79,7 @@ jobs:
path: docs/src/data/
- name: Try to download latest diagnostics (for push/dispatch triggers)
if: github.event_name != 'workflow_run'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
+1 -1
View File
@@ -111,7 +111,7 @@ jobs:
run: |
yarn install --check-cache
- name: Download database diagnostics from integration tests
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
workflow: superset-python-integrationtest.yml
run_id: ${{ github.event.workflow_run.id }}
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.16.4
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.5.4
+1 -1
View File
@@ -70,7 +70,7 @@
"@swc/core": "^1.15.21",
"antd": "^6.3.5",
"baseline-browser-mapping": "^2.10.13",
"caniuse-lite": "^1.0.30001782",
"caniuse-lite": "^1.0.30001786",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
"js-yaml": "^4.1.1",
+4 -4
View File
@@ -6067,10 +6067,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001782:
version "1.0.30001782"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz#f2b8617f998bc134701c54ce9748af44f646e062"
integrity sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001786:
version "1.0.30001786"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz#586120fc73f3c7ee82152f76acd0c37e04acefbb"
integrity sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==
ccount@^2.0.0:
version "2.0.1"
+6 -6
View File
@@ -86,7 +86,7 @@ cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.0.0
# via apache-superset (pyproject.toml)
cryptography==46.0.5
cryptography==46.0.6
# via
# apache-superset (pyproject.toml)
# paramiko
@@ -209,7 +209,7 @@ mako==1.3.10
# via
# apache-superset (pyproject.toml)
# alembic
markdown==3.8
markdown==3.8.1
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
@@ -279,7 +279,7 @@ parsedatetime==2.6
# via apache-superset (pyproject.toml)
pgsanity==0.2.9
# via apache-superset (pyproject.toml)
pillow==11.3.0
pillow==12.1.1
# via apache-superset (pyproject.toml)
platformdirs==4.3.8
# via requests-cache
@@ -293,7 +293,7 @@ prompt-toolkit==3.0.51
# via click-repl
pyarrow==16.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.2
pyasn1==0.6.3
# via
# pyasn1-modules
# rsa
@@ -309,9 +309,9 @@ pydantic-core==2.33.2
# via pydantic
pygeohash==3.2.2
# via apache-superset (pyproject.toml)
pygments==2.19.1
pygments==2.20.0
# via rich
pyjwt==2.10.1
pyjwt==2.12.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
+6 -6
View File
@@ -178,7 +178,7 @@ croniter==6.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
cryptography==46.0.5
cryptography==46.0.6
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -508,7 +508,7 @@ mako==1.3.10
# -c requirements/base-constraint.txt
# alembic
# apache-superset
markdown==3.8
markdown==3.8.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -655,7 +655,7 @@ pgsanity==0.2.9
# via
# -c requirements/base-constraint.txt
# apache-superset
pillow==11.3.0
pillow==12.1.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -716,7 +716,7 @@ pyarrow==16.1.0
# apache-superset
# db-dtypes
# pandas-gbq
pyasn1==0.6.2
pyasn1==0.6.3
# via
# -c requirements/base-constraint.txt
# pyasn1-modules
@@ -756,7 +756,7 @@ pygeohash==3.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
pygments==2.19.1
pygments==2.20.0
# via
# -c requirements/base-constraint.txt
# rich
@@ -764,7 +764,7 @@ pyhive==0.7.0
# via apache-superset
pyinstrument==4.4.0
# via apache-superset
pyjwt==2.10.1
pyjwt==2.12.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -1,171 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CHART_LIST } from 'cypress/utils/urls';
import { setGridMode, toggleBulkSelect } from 'cypress/utils';
import {
setFilter,
interceptBulkDelete,
interceptUpdate,
interceptDelete,
interceptFiltering,
interceptFavoriteStatus,
} from '../explore/utils';
function orderAlphabetical() {
setFilter('Sort', 'Alphabetical');
}
function openProperties() {
cy.get('[aria-label="more"]').eq(0).click();
cy.getBySel('chart-list-edit-option').click();
}
function openMenu() {
cy.get('[aria-label="more"]').eq(0).click();
}
function confirmDelete() {
cy.getBySel('delete-modal-input').type('DELETE');
cy.getBySel('modal-confirm-button').click();
}
function visitChartList() {
interceptFiltering();
interceptFavoriteStatus();
cy.visit(CHART_LIST);
cy.wait('@filtering');
cy.wait('@favoriteStatus');
}
describe('Charts list', () => {
describe('common actions', () => {
beforeEach(() => {
visitChartList();
});
it('should bulk delete correctly', () => {
cy.createSampleCharts([0, 1, 2, 3]);
interceptBulkDelete();
toggleBulkSelect();
// bulk deletes in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('skeleton-card').should('not.exist');
cy.getBySel('styled-card').contains('1 - Sample chart').click();
cy.getBySel('styled-card').contains('2 - Sample chart').click();
cy.getBySel('bulk-select-action').contains('Delete').click();
confirmDelete();
cy.wait('@bulkDelete');
cy.getBySel('styled-card')
.eq(1)
.should('not.contain', '1 - Sample chart');
cy.getBySel('styled-card')
.eq(2)
.should('not.contain', '2 - Sample chart');
// bulk deletes in list-view
setGridMode('list');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').contains('3 - Sample chart').should('exist');
cy.getBySel('table-row').contains('4 - Sample chart').should('exist');
cy.get('[data-test="table-row"] input[type="checkbox"]').eq(0).click();
cy.get('[data-test="table-row"] input[type="checkbox"]').eq(1).click();
cy.getBySel('bulk-select-action').eq(0).contains('Delete').click();
confirmDelete();
cy.wait('@bulkDelete');
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').eq(0).should('not.contain', '3 - Sample chart');
cy.getBySel('table-row').eq(1).should('not.contain', '4 - Sample chart');
});
it('should delete correctly in card mode', () => {
cy.createSampleCharts([0, 1]);
interceptDelete();
// deletes in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('styled-card').contains('1 - Sample chart');
openMenu();
cy.getBySel('chart-list-delete-option').click();
confirmDelete();
cy.wait('@delete');
cy.getBySel('styled-card')
.contains('1 - Sample chart')
.should('not.exist');
});
it('should delete correctly in list mode', () => {
cy.createSampleCharts([2, 3]);
interceptDelete();
cy.getBySel('sort-header').contains('Name').click();
// Modal closes immediately without this
cy.wait(2000);
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');
cy.getBySel('delete').eq(0).click();
confirmDelete();
cy.wait('@delete');
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').eq(0).should('not.contain', '3 - Sample chart');
});
it('should edit correctly', () => {
cy.createSampleCharts([0]);
interceptUpdate();
// edits in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('skeleton-card').should('not.exist');
cy.getBySel('styled-card').eq(0).contains('1 - Sample chart');
// change title
openProperties();
cy.getBySel('properties-modal-name-input').type(' | EDITED');
cy.get('button:contains("Save")').click();
cy.wait('@update');
cy.getBySel('styled-card').eq(0).contains('1 - Sample chart | EDITED');
// edits in list-view
setGridMode('list');
// Wait for list view to fully render after mode change
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').should('be.visible');
// Target the specific row by chart title to avoid flakiness from row ordering
cy.getBySel('table-row')
.contains('1 - Sample chart | EDITED')
.parents('[data-test="table-row"]')
.find('[data-test="edit-alt"]')
.click();
cy.getBySel('properties-modal-name-input').clear();
cy.getBySel('properties-modal-name-input').type('1 - Sample chart');
cy.get('button:contains("Save")').click();
cy.wait('@update');
cy.getBySel('table-row').contains('1 - Sample chart').should('exist');
});
});
});
@@ -1,42 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { DATASET_LIST_PATH } from 'cypress/utils/urls';
describe('Dataset list', () => {
before(() => {
cy.visit(DATASET_LIST_PATH);
});
xit('should open Explore on dataset name click', () => {
cy.intercept('**/api/v1/explore/**').as('explore');
cy.get('[data-test="listview-table"] [data-test="internal-link"]')
.contains('birth_names')
.click();
cy.wait('@explore');
cy.get('[data-test="datasource-control"] .title-select').contains(
'birth_names',
);
cy.get('.metric-option-label').first().contains('COUNT(*)');
cy.get('.column-option-label').first().contains('ds');
cy.get('[data-test="fast-viz-switcher"] > div:not([role="button"]')
.contains('Table')
.should('be.visible');
});
});
@@ -23,18 +23,6 @@ export function interceptFiltering() {
cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering');
}
export function interceptBulkDelete() {
cy.intercept('DELETE', `**/api/v1/chart/?q=*`).as('bulkDelete');
}
export function interceptDelete() {
cy.intercept('DELETE', `**/api/v1/chart/*`).as('delete');
}
export function interceptFavoriteStatus() {
cy.intercept('GET', '**/api/v1/chart/favorite_status/*').as('favoriteStatus');
}
export function interceptUpdate() {
cy.intercept('PUT', `**/api/v1/chart/*`).as('update');
}
@@ -43,32 +31,13 @@ export const interceptV1ChartData = (alias = 'v1Data') => {
cy.intercept('**/api/v1/chart/data*').as(alias);
};
export function interceptExploreJson(alias = 'getJson') {
cy.intercept('POST', `**/superset/explore_json/**`).as(alias);
}
export const interceptFormDataKey = () => {
cy.intercept('POST', '**/api/v1/explore/form_data').as('formDataKey');
};
export function interceptExploreGet() {
function interceptExploreGet() {
cy.intercept({
method: 'GET',
url: /.*\/api\/v1\/explore\/\?(form_data_key|dashboard_page_id|slice_id)=.*/,
}).as('getExplore');
}
export function setFilter(filter: string, option: string) {
interceptFiltering();
cy.get(`[aria-label^="${filter}"]`).first().click();
cy.get(`.ant-select-item-option[title="${option}"]`).first().click({
force: true,
});
cy.wait('@filtering');
}
export function saveChartToDashboard(chartName: string, dashboardName: string) {
interceptDashboardGet();
interceptUpdate();
@@ -25,28 +25,6 @@ export interface ChartSpec {
viz: string;
}
const viewTypeIcons = {
card: 'appstore',
list: 'unordered-list',
};
export function setGridMode(type: 'card' | 'list') {
const icon = viewTypeIcons[type];
cy.get(`[aria-label="${icon}"]`).click();
}
export function toggleBulkSelect() {
cy.getBySel('bulk-select').click();
}
export function clearAllInputs() {
cy.get('body').then($body => {
if ($body.find('.ant-select-clear').length) {
cy.get('.ant-select-clear').click({ multiple: true, force: true });
}
});
}
const toSlicelike = ($chart: JQuery<HTMLElement>): Slice => {
const chartId = $chart.attr('data-test-chart-id');
const vizType = $chart.attr('data-test-viz-type');
@@ -25,8 +25,3 @@ export const SUPPORTED_CHARTS_DASHBOARD =
'/superset/dashboard/supported_charts_dash/';
export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/';
export const DATABASE_LIST = '/databaseview/list';
export const DATASET_LIST_PATH = 'tablemodelview/list';
export const ALERT_LIST = '/alert/list/';
export const REPORT_LIST = '/report/list/';
export const LOGIN = '/login/';
export const REGISTER = '/register/';
+6 -6
View File
@@ -5809,9 +5809,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
@@ -13072,9 +13072,9 @@
}
},
"lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"lodash.clonedeep": {
"version": "4.5.0",
+897 -931
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -169,7 +169,7 @@
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.4",
"fuse.js": "^7.1.0",
"geolib": "^3.3.4",
"geolib": "^3.3.14",
"geostyler": "^18.3.1",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.4.1",
@@ -182,9 +182,9 @@
"js-levenshtein": "^1.1.6",
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"mapbox-gl": "^3.20.0",
"markdown-to-jsx": "^9.7.6",
"markdown-to-jsx": "^9.7.13",
"match-sorter": "^8.2.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -223,7 +223,7 @@
"redux-undo": "^1.0.0-beta9-9-7",
"rison": "^0.1.1",
"scroll-into-view-if-needed": "^3.1.0",
"simple-zstd": "^2.1.0",
"simple-zstd": "^1.4.2",
"stream-browserify": "^3.0.0",
"tinycolor2": "^1.4.2",
"urijs": "^1.19.8",
@@ -244,7 +244,7 @@
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
@@ -256,7 +256,7 @@
"@emotion/jest": "^11.14.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.59.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-actions": "^8.6.17",
"@storybook/addon-controls": "^8.6.17",
@@ -270,8 +270,8 @@
"@storybook/test": "^8.6.15",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.18",
"@swc/plugin-emotion": "^14.6.0",
"@swc/core": "^1.15.24",
"@swc/plugin-emotion": "^14.8.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -284,7 +284,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.3.3",
"@types/node": "^25.5.0",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -301,11 +301,11 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
"babel-loader": "^10.1.0",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.10",
"baseline-browser-mapping": "^2.10.13",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -327,7 +327,7 @@
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.2",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.1",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
@@ -337,13 +337,13 @@
"imports-loader": "^5.0.0",
"jest": "^30.3.0",
"jest-environment-jsdom": "^29.7.0",
"jest-html-reporter": "^4.3.0",
"jest-html-reporter": "^4.4.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^28.1.0",
"jsdom": "^29.0.2",
"lerna": "^9.0.4",
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.1",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.56.0",
"po2json": "^0.4.5",
@@ -361,7 +361,7 @@
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.4.0",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.6",
"ts-jest": "^29.4.9",
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",
@@ -29,8 +29,8 @@
},
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.17.23",
"yeoman-generator": "^7.5.1",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.1.2",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -75,7 +75,7 @@
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"typescript": "^5.0.0",
@@ -102,7 +102,7 @@
"react-dom": "^17.0.2",
"react-loadable": "^5.5.0",
"tinycolor2": "*",
"lodash": "^4.17.21",
"lodash": "^4.18.1",
"antd": "^5.26.0",
"jed": "^1.1.1"
},
@@ -26,11 +26,11 @@
"dependencies": {
"@apache-superset/core": "*",
"@types/react": "*",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"tinycolor2": "*"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.4.1",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",
@@ -24,39 +24,40 @@
"lib"
],
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.29.2",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.43.6",
"ag-grid-community": "35.0.1",
"ag-grid-react": "35.0.1",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"csstype": "^3.2.3",
"core-js": "^3.49.0",
"csstype": "^3.2.3",
"d3-format": "^3.1.2",
"dayjs": "^1.11.20",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"math-expression-evaluator": "^2.0.7",
"pretty-ms": "^9.3.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-js-cron": "^5.2.0",
"react-draggable": "^4.5.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"react-error-boundary": "6.0.0",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -64,7 +65,6 @@
"reselect": "^5.1.1",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"@visx/responsive": "^3.12.0",
"xss": "^1.0.15"
},
"devDependencies": {
@@ -74,12 +74,12 @@
"@types/d3-scale": "^2.1.1",
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/react-table": "^7.7.20",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.3",
"@types/node": "^25.5.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
"fetch-mock": "^12.6.0",
@@ -88,7 +88,6 @@
"timezone-mock": "1.4.0"
},
"peerDependencies": {
"antd": "^5.26.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
@@ -101,6 +100,7 @@
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"antd": "^5.26.0",
"nanoid": "^5.0.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@@ -283,6 +283,16 @@ export function AsyncAceEditor(
color: ${token.colorText} !important;
}
/* Fix cursor misalignment by ensuring consistent font-family */
.ace_editor .ace_content {
font-family: ${editorFontFamily} !important;
}
/* Ensure the text layer uses the same font-family */
.ace_editor .ace_text-layer {
font-family: ${editorFontFamily} !important;
}
/* Adjust gutter colors */
.ace_editor .ace_gutter {
background-color: ${token.colorBgElevated} !important;
@@ -18,6 +18,9 @@
*/
// Specific modal implementations
export { ChartPropertiesModal } from './ChartPropertiesModal';
export { ConfirmDialog } from './ConfirmDialog';
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { EditDatasetModal } from './EditDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';
@@ -47,7 +47,7 @@ export class ChartListPage {
}
/**
* Navigate to the chart list page.
* Navigate to the chart list page in table view.
* Forces table view via URL parameter to avoid card view default
* (ListviewsDefaultCardView feature flag may enable card view).
*/
@@ -55,6 +55,13 @@ export class ChartListPage {
await this.page.goto(`${URL.CHART_LIST}?viewMode=table`);
}
/**
* Navigate to the chart list page in card view.
*/
async gotoCardView(): Promise<void> {
await this.page.goto(`${URL.CHART_LIST}?viewMode=card`);
}
/**
* Wait for the table to load
* @param options - Optional wait options
@@ -63,6 +70,16 @@ export class ChartListPage {
await this.table.waitForVisible(options);
}
/**
* Wait for card view to finish loading.
*/
async waitForCardLoad(options?: { timeout?: number }): Promise<void> {
await this.page
.locator('[data-test="styled-card"]')
.first()
.waitFor({ state: 'visible', ...options });
}
/**
* Gets a chart row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
@@ -129,4 +146,24 @@ export class ChartListPage {
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
}
// --- Card view methods ---
/**
* Gets a chart card locator by name (card view).
*/
getChartCard(chartName: string): Locator {
return this.page
.locator('[data-test="styled-card"]')
.filter({ hasText: chartName });
}
/**
* Clicks the edit option in a chart card's dropdown menu (card view).
*/
async clickCardEditAction(chartName: string): Promise<void> {
const card = this.getChartCard(chartName);
await card.locator('[aria-label="more"]').click();
await this.page.locator('[data-test="chart-list-edit-option"]').click();
}
}
@@ -17,21 +17,20 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { ChartListPage } from '../../pages/ChartListPage';
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { ChartListPage } from '../../../pages/ChartListPage';
import { ChartPropertiesModal } from '../../../components/modals/ChartPropertiesModal';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { Toast } from '../../../components/core/Toast';
import { apiGetChart, ENDPOINTS } from '../../../helpers/api/chart';
ChartPropertiesModal,
DeleteConfirmationModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import { apiGetChart, ENDPOINTS } from '../../helpers/api/chart';
import { createTestChart } from './chart-test-helpers';
import { waitForGet, waitForPut } from '../../../helpers/api/intercepts';
import { waitForGet, waitForPut } from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
} from '../../helpers/api/assertions';
/**
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
@@ -261,6 +260,60 @@ test('should bulk delete multiple charts', async ({
}
});
test('should edit chart name from card view', async ({ page, testAssets }) => {
// Create throwaway chart for editing
const { id: chartId, name: chartName } = await createTestChart(
page,
testAssets,
test.info(),
{ prefix: 'test_card_edit' },
);
// Navigate to card view (not table view)
const cardListPage = new ChartListPage(page);
await cardListPage.gotoCardView();
await cardListPage.waitForCardLoad();
// Verify chart card is visible
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
// Open card dropdown and click edit
await cardListPage.clickCardEditAction(chartName);
// Wait for properties modal to be ready
const propertiesModal = new ChartPropertiesModal(page);
await propertiesModal.waitForReady();
// Edit the chart name
const newName = `card_renamed_${Date.now()}_${test.info().parallelIndex}`;
await propertiesModal.fillName(newName);
// Set up response intercept for save
const saveResponsePromise = waitForPut(page, `${ENDPOINTS.CHART}${chartId}`);
// Click Save button
await propertiesModal.clickSave();
// Wait for save to complete and verify success
expectStatusOneOf(await saveResponsePromise, [200, 201]);
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify the renamed card appears in card view and old name is gone
await expect(cardListPage.getChartCard(newName)).toBeVisible();
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
// Backend verification: API returns updated name
const response = await apiGetChart(page, chartId);
const chart = (await response.json()).result;
expect(chart.slice_name).toBe(newName);
});
test('should bulk export multiple charts', async ({
page,
chartListPage,
@@ -18,9 +18,9 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostChart } from '../../../helpers/api/chart';
import { getDatasetByName } from '../../../helpers/api/dataset';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
interface TestChartResult {
id: number;
@@ -17,28 +17,27 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { DashboardListPage } from '../../pages/DashboardListPage';
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { DashboardListPage } from '../../../pages/DashboardListPage';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { Toast } from '../../../components/core/Toast';
DeleteConfirmationModal,
ImportDatasetModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import {
apiGetDashboard,
apiDeleteDashboard,
apiExportDashboards,
getDashboardByName,
ENDPOINTS,
} from '../../../helpers/api/dashboard';
} from '../../helpers/api/dashboard';
import { createTestDashboard } from './dashboard-test-helpers';
import { waitForGet, waitForPost } from '../../../helpers/api/intercepts';
import { waitForGet, waitForPost } from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with dashboardListPage navigation (beforeEach equivalent).
@@ -18,8 +18,8 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostDashboard } from '../../../helpers/api/dashboard';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostDashboard } from '../../helpers/api/dashboard';
interface TestDashboardResult {
id: number;
@@ -18,9 +18,9 @@
*/
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../../../pages/DashboardPage';
import { Toast } from '../../../components/core';
import { TIMEOUT } from '../../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import { Toast } from '../../components/core';
import { TIMEOUT } from '../../utils/constants';
/**
* Dashboard Export E2E tests.
@@ -18,16 +18,16 @@
*/
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../../pages/AuthPage';
import { DashboardPage } from '../../../pages/DashboardPage';
import { apiPostTheme, apiDeleteTheme } from '../../../helpers/api/theme';
import { AuthPage } from '../../pages/AuthPage';
import { DashboardPage } from '../../pages/DashboardPage';
import { apiPostTheme, apiDeleteTheme } from '../../helpers/api/theme';
import {
apiPostDashboard,
apiPutDashboard,
apiDeleteDashboard,
} from '../../../helpers/api/dashboard';
import { apiGet } from '../../../helpers/api/requests';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/dashboard';
import { apiGet } from '../../helpers/api/requests';
import { TIMEOUT } from '../../utils/constants';
/**
* Dashboard Theme E2E tests.
@@ -17,17 +17,17 @@
* under the License.
*/
import { test, expect } from '../../../helpers/fixtures/testAssets';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { testWithAssets as test, expect } from '../../helpers/fixtures';
import type { TestAssets } from '../../helpers/fixtures';
import type { Page, TestInfo } from '@playwright/test';
import { ExplorePage } from '../../../pages/ExplorePage';
import { CreateDatasetPage } from '../../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ChartCreationPage } from '../../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../../helpers/api/dataset';
import { waitForPost } from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import { apiPostDatabase } from '../../../helpers/api/database';
import { ExplorePage } from '../../pages/ExplorePage';
import { CreateDatasetPage } from '../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ChartCreationPage } from '../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../helpers/api/dataset';
import { waitForPost } from '../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../helpers/api/assertions';
import { apiPostDatabase } from '../../helpers/api/database';
interface GsheetsSetupResult {
sheetName: string;
@@ -17,37 +17,36 @@
* under the License.
*/
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { testWithAssets, expect } from '../../helpers/fixtures';
import path from 'path';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
import { EditDatasetModal } from '../../../components/modals/EditDatasetModal';
import { Toast } from '../../../components/core/Toast';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ExplorePage } from '../../pages/ExplorePage';
import {
ConfirmDialog,
DeleteConfirmationModal,
DuplicateDatasetModal,
EditDatasetModal,
ImportDatasetModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import {
apiDeleteDataset,
apiGetDataset,
apiPostVirtualDataset,
getDatasetByName,
ENDPOINTS,
} from '../../../helpers/api/dataset';
} from '../../helpers/api/dataset';
import { createTestDataset } from './dataset-test-helpers';
import {
waitForGet,
waitForPost,
waitForPut,
} from '../../../helpers/api/intercepts';
} from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with datasetListPage navigation (beforeEach equivalent).
@@ -458,11 +457,12 @@ test.describe('import dataset', () => {
testAssets,
}) => {
// Dataset name from fixture (test_netflix_1768502050965)
// Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable
// Note: Fixture contains a Google Sheets dataset backed by shillelagh[gsheetsapi],
// which is a base dependency — import failure fails the test hard (no skip).
const importedDatasetName = 'test_netflix_1768502050965';
const fixturePath = path.resolve(
__dirname,
'../../../fixtures/dataset_export.zip',
'../../fixtures/dataset_export.zip',
);
// Cleanup: Delete any existing dataset with the same name from previous runs
@@ -518,25 +518,12 @@ test.describe('import dataset', () => {
importResponse = await importResponsePromise;
}
// Check final import response for gsheets connector errors
// Fail hard if dataset import fails.
// The fixture contains a gsheets dataset; shillelagh[gsheetsapi] is a base
// dependency (pyproject.toml), so the engine is always available in CI.
if (!importResponse.ok()) {
const errorBody = await importResponse.json().catch(() => ({}));
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine') ||
errorText.includes('Could not load database driver')
) {
await test.info().attach('skip-reason', {
body: `Import failed due to missing gsheets connector: ${errorText}`,
contentType: 'text/plain',
});
test.skip();
return;
}
// Re-throw other errors
throw new Error(`Import failed: ${errorText}`);
throw new Error(`Import failed: ${JSON.stringify(errorBody)}`);
}
// Modal should close on success
@@ -18,8 +18,8 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { createTestVirtualDataset } from '../../../helpers/api/dataset';
import type { TestAssets } from '../../helpers/fixtures';
import { createTestVirtualDataset } from '../../helpers/api/dataset';
interface TestDatasetResult {
id: number;
@@ -21,6 +21,7 @@
import d3 from 'd3';
import { extent as d3Extent } from 'd3-array';
import {
ValueFormatter,
getNumberFormatter,
getSequentialSchemeRegistry,
CategoricalColorNamespace,
@@ -60,7 +61,8 @@ interface CountryMapProps {
height: number;
country: string;
linearColorScheme: string;
numberFormat: string;
numberFormat?: string; // left for backward compatibility
formatter: ValueFormatter;
colorScheme: string;
sliceId: number;
}
@@ -74,13 +76,12 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
height,
country,
linearColorScheme,
numberFormat,
formatter,
colorScheme,
sliceId,
} = props;
const container = element;
const format = getNumberFormatter(numberFormat);
const rawExtents = d3Extent(data, v => v.metric);
const extents: [number, number] =
rawExtents[0] != null && rawExtents[1] != null
@@ -182,7 +183,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.style('top', `${position[1] + 30}px`)
.style('left', `${position[0]}px`)
.html(
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? format(result[0].metric) : ''}</div>`,
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
);
};
@@ -69,6 +69,7 @@ const config: ControlPanelConfig = {
},
},
],
['currency_format'],
['linear_color_scheme'],
],
},
@@ -16,26 +16,48 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps } from '@superset-ui/core';
import { ChartProps, getValueFormatter } from '@superset-ui/core';
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, queriesData } = chartProps;
const { width, height, formData, queriesData, datasource } = chartProps;
const {
linearColorScheme,
numberFormat,
currencyFormat,
selectCountry,
colorScheme,
sliceId,
metric,
} = formData;
const {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
} = datasource;
const { data, detected_currency: detectedCurrency } = queriesData[0];
const formatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
currencyFormat,
undefined, // key - not needed for single-metric charts
data,
currencyCodeColumn,
detectedCurrency,
);
return {
width,
height,
data: queriesData[0].data,
country: selectCountry ? String(selectCountry).toLowerCase() : null,
linearColorScheme,
numberFormat,
numberFormat, // left for backward compatibility
colorScheme,
sliceId,
formatter,
};
}
@@ -93,6 +93,7 @@ describe('CountryMap (legacy d3)', () => {
linearColorScheme="bnbColors"
colorScheme=""
numberFormat=".2f"
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -115,6 +116,7 @@ describe('CountryMap (legacy d3)', () => {
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -144,6 +146,7 @@ describe('CountryMap (legacy d3)', () => {
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -24,13 +24,13 @@
"lib"
],
"dependencies": {
"@deck.gl/aggregation-layers": "~9.2.5",
"@deck.gl/aggregation-layers": "~9.2.11",
"@deck.gl/core": "~9.2.5",
"@deck.gl/extensions": "~9.2.5",
"@deck.gl/extensions": "~9.2.9",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.5",
"@deck.gl/react": "~9.2.11",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.6",
@@ -46,7 +46,7 @@
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"handlebars": "^4.7.9",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
"prop-types": "^15.8.1",
@@ -32,7 +32,7 @@
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.3.3",
"prop-types": "^15.8.1",
@@ -28,15 +28,15 @@
"@types/react-table": "^7.7.20",
"classnames": "^2.5.1",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"memoize-one": "^5.2.1",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",
@@ -31,14 +31,14 @@
"dependencies": {
"@types/geojson": "^7946.0.16",
"geojson": "^0.5.0",
"lodash": "^4.17.23"
"lodash": "^4.18.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@reduxjs/toolkit": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@types/react-redux": "*",
"geostyler": "^18.3.1",
"geostyler-data": "^1.0.0",
@@ -28,7 +28,7 @@
"@types/react-redux": "^7.1.34",
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"zod": "^4.3.6"
},
"peerDependencies": {
@@ -659,7 +659,10 @@ export default function transformProps(
for (const s of series) {
if (s.id) {
const columnsArr = labelMap[s.id];
(s as any).stack = columnsArr[idxSelectedDimension];
const dimensionValue = columnsArr?.[idxSelectedDimension];
if (dimensionValue !== undefined) {
(s as any).stack = dimensionValue;
}
}
}
}
@@ -682,9 +685,24 @@ export default function transformProps(
// For horizontal bar charts, set max/min from calculated data bounds
if (shouldCalculateDataBounds) {
// Set max to actual data max to avoid gaps and ensure labels are visible
if (dataMax !== undefined && yAxisMax === undefined) {
yAxisMax = dataMax;
// For stacked charts, clamp against the per-row stacked total to avoid
// clipping bars. Also keep dataMax so that mixed-sign stacks (where
// positive and negative values cancel in the algebraic row sum) cannot
// produce an axis max smaller than the largest individual positive segment.
const stackedTotalMax = Math.max(
...sortedTotalValues.filter(
(v): v is number => typeof v === 'number' && !Number.isNaN(v),
),
);
const effectiveDataMax = stack
? Math.max(dataMax ?? Number.NEGATIVE_INFINITY, stackedTotalMax)
: dataMax;
if (
effectiveDataMax !== undefined &&
Number.isFinite(effectiveDataMax) &&
yAxisMax === undefined
) {
yAxisMax = effectiveDataMax;
}
// Set min to actual data min for diverging bars
if (dataMin !== undefined && yAxisMin === undefined && dataMin < 0) {
@@ -24,6 +24,7 @@ import {
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import { StackControlsValue } from '../../../src/constants';
import type {
GridComponentOption,
LegendComponentOption,
@@ -727,6 +728,97 @@ describe('Bar Chart X-axis Time Formatting', () => {
});
});
describe('Horizontal stacked bar chart axis bounds', () => {
// Dataset where each series max = 4 but stacked total max = 8
const stackedData: ChartDataResponseResult[] = [
createTestQueryData(
[
{ team: 'Team A', High: 2, Low: 2, Medium: 4 },
{ team: 'Team B', High: null, Low: null, Medium: 3 },
{ team: 'Team C', High: null, Low: null, Medium: 1 },
],
{
colnames: ['team', 'High', 'Low', 'Medium'],
coltypes: [
GenericDataType.String,
GenericDataType.Numeric,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
},
),
];
const horizontalStackedFormData: EchartsTimeseriesFormData = {
...(baseFormData as EchartsTimeseriesFormData),
x_axis: 'team',
metric: ['High', 'Low', 'Medium'],
groupby: [],
orientation: OrientationType.Horizontal,
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
truncateYAxis: true,
};
test('xAxis.max uses stacked total, not individual series max', () => {
// Individual series max = 4 (Medium), stacked total for Team A = 8
// Without the fix, xAxis.max would be 4, clipping bars and duplicating labels
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: horizontalStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// xAxis.max must be >= stacked total (8), not capped at individual series max (4)
expect(xAxis.max).toBeGreaterThanOrEqual(8);
});
test('xAxis.max is not set to individual series max when stacking', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: horizontalStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// 4 is the individual series max — the axis should not be clipped there
expect(xAxis.max).not.toBe(4);
});
test('non-stacked horizontal bar chart still uses individual series max', () => {
const nonStackedFormData: EchartsTimeseriesFormData = {
...horizontalStackedFormData,
stack: null,
};
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: nonStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// Without stacking, xAxis.max should be based on individual series values
expect(xAxis.max).toBe(4);
});
});
describe('Legend layout regressions', () => {
const getBottomLegendLayout = (
chartWidth: number,
@@ -37,7 +37,7 @@
"@superset-ui/core": "*",
"ace-builds": "^1.4.14",
"handlebars": "^4.7.8",
"lodash": "^4.17.11",
"lodash": "^4.18.1",
"dayjs": "^1.11.19",
"react": "^17.0.2",
"react-ace": "^10.1.0",
@@ -27,15 +27,15 @@
"access": "public"
},
"peerDependencies": {
"react-icons": "5.4.0",
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"lodash": "^4.17.11",
"lodash": "^4.18.1",
"prop-types": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"react-dom": "^17.0.2",
"react-icons": "5.4.0"
},
"devDependencies": {
"@babel/types": "^7.29.0",
@@ -24,20 +24,20 @@
"lib"
],
"dependencies": {
"react-icons": "5.4.0",
"@types/d3-array": "^3.2.2",
"@types/react-table": "^7.7.20",
"classnames": "^2.5.1",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"memoize-one": "^5.2.1",
"react-icons": "5.4.0",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",
@@ -34,9 +34,9 @@
"d3-scale": "^4.0.2"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@types/lodash": "*",
"@types/react": "*",
"react": "^17.0.2"
@@ -45,11 +45,19 @@ const DatasourceEditor = AsyncEsmComponent(
() => import('../components/DatasourceEditor'),
);
const MODAL_HEIGHT_VH = 90;
const TOP_MARGIN_VH = (100 - MODAL_HEIGHT_VH) / 2;
const StyledDatasourceModal = styled(Modal)`
top: ${TOP_MARGIN_VH}vh;
padding-bottom: 0;
&& .ant-modal-content {
max-height: none;
margin-top: 0;
margin-bottom: 0;
min-height: 500px;
min-width: 500px;
}
&& .ant-modal-body {
@@ -367,7 +375,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
}
responsive
resizable
resizableConfig={{ defaultSize: { width: 'auto', height: '900px' } }}
resizableConfig={{
defaultSize: { width: 'auto', height: `${MODAL_HEIGHT_VH}vh` },
}}
draggable
>
<DatasourceEditor
@@ -33,7 +33,6 @@ export const FoldersToolbar = styled.div`
top: -${theme.margin}px; // offsets tabs component bottom margin
z-index: 10;
background: ${theme.colorBgContainer};
padding-top: ${theme.paddingMD}px;
display: flex;
flex-direction: column;
gap: ${theme.paddingLG}px;
@@ -370,6 +370,7 @@ const StyledTableTabs = styled(Tabs)`
flex: 1;
min-height: 0;
overflow: auto;
padding-top: ${({ theme }) => theme.paddingMD}px;
}
.ant-tabs-content {
@@ -2525,18 +2526,20 @@ class DatasourceEditor extends PureComponent<
key: TABS_KEYS.SETTINGS,
label: t('Settings'),
children: (
<Row gutter={16}>
<Col xs={24} md={12}>
<FormContainer>
{this.renderSettingsFieldset()}
</FormContainer>
</Col>
<Col xs={24} md={12}>
<FormContainer>
{this.renderAdvancedFieldset()}
</FormContainer>
</Col>
</Row>
<div style={{ overflowX: 'hidden' }}>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormContainer>
{this.renderSettingsFieldset()}
</FormContainer>
</Col>
<Col xs={24} md={12}>
<FormContainer>
{this.renderAdvancedFieldset()}
</FormContainer>
</Col>
</Row>
</div>
),
},
]}
@@ -181,7 +181,7 @@ const DetailsPanelPopover = ({
return (
<Popover
color={`${theme.colorBgElevated}cc`}
color={theme.colorBgElevated}
content={content}
open={popoverVisible}
onOpenChange={handleVisibility}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
/**
* 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 { useState } from 'react';
import {
render,
screen,
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { AlertReportCronScheduler } from './AlertReportCronScheduler';
const defaultProps = {
value: '0 12 * * 1',
onChange: jest.fn(),
};
beforeEach(() => {
defaultProps.onChange = jest.fn();
});
test('renders CronPicker by default (picker mode)', () => {
render(<AlertReportCronScheduler {...defaultProps} />);
expect(screen.getByText('Schedule type')).toBeInTheDocument();
expect(screen.getByText('Schedule')).toBeInTheDocument();
// CronPicker renders combobox elements; CRON text input does not
expect(
screen.queryByPlaceholderText('CRON expression'),
).not.toBeInTheDocument();
});
async function switchToCronInputMode() {
const scheduleTypeSelect = screen.getByRole('combobox', {
name: /Schedule type/i,
});
fireEvent.mouseDown(scheduleTypeSelect);
await waitFor(() => {
expect(screen.getByText('CRON Schedule')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('CRON Schedule'));
}
test('switches to CRON input mode and shows text input', async () => {
render(<AlertReportCronScheduler {...defaultProps} />);
await switchToCronInputMode();
await waitFor(() => {
expect(screen.getByPlaceholderText('CRON expression')).toBeInTheDocument();
});
});
// Controlled wrapper: the component is fully controlled (value from props),
// so blur/enter tests need a parent that updates value on onChange.
function ControlledScheduler({
initialValue,
onChangeSpy,
}: {
initialValue: string;
onChangeSpy: jest.Mock;
}) {
const [value, setValue] = useState(initialValue);
return (
<AlertReportCronScheduler
value={value}
onChange={(v: string) => {
setValue(v);
onChangeSpy(v);
}}
/>
);
}
test('calls onChange on blur in CRON input mode', async () => {
const onChangeSpy = jest.fn();
render(
<ControlledScheduler initialValue="0 12 * * 1" onChangeSpy={onChangeSpy} />,
);
await switchToCronInputMode();
const input = await screen.findByPlaceholderText('CRON expression');
fireEvent.change(input, { target: { value: '*/5 * * * *' } });
// Clear spy so we only assert the blur-specific call
onChangeSpy.mockClear();
fireEvent.blur(input);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
expect(onChangeSpy).toHaveBeenCalledWith('*/5 * * * *');
});
test('calls onChange on Enter key press in CRON input mode', async () => {
const onChangeSpy = jest.fn();
render(
<ControlledScheduler initialValue="0 12 * * 1" onChangeSpy={onChangeSpy} />,
);
await switchToCronInputMode();
const input = await screen.findByPlaceholderText('CRON expression');
fireEvent.change(input, { target: { value: '0 9 * * 1-5' } });
// Clear spy so we only assert the Enter-specific call
onChangeSpy.mockClear();
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onChangeSpy).toHaveBeenCalledTimes(1);
expect(onChangeSpy).toHaveBeenCalledWith('0 9 * * 1-5');
});
@@ -0,0 +1,67 @@
/**
* 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, fireEvent } from 'spec/helpers/testing-library';
import NumberInput from './NumberInput';
const defaultProps = {
timeUnit: 'seconds',
min: 0,
name: 'timeout',
value: '30',
placeholder: 'Enter value',
onChange: jest.fn(),
};
test('renders value with timeUnit suffix when not focused', () => {
render(<NumberInput {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter value');
expect(input).toHaveValue('30 seconds');
});
test('strips suffix on focus and restores on blur', () => {
render(<NumberInput {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter value');
fireEvent.focus(input);
expect(input).toHaveValue('30');
fireEvent.blur(input);
expect(input).toHaveValue('30 seconds');
});
test('renders empty string when value is falsy', () => {
render(<NumberInput {...defaultProps} value="" />);
const input = screen.getByPlaceholderText('Enter value');
expect(input).toHaveValue('');
});
test('renders empty string when value is zero', () => {
render(<NumberInput {...defaultProps} value={0} />);
const input = screen.getByPlaceholderText('Enter value');
expect(input).toHaveValue('');
});
test('calls onChange when input changes', () => {
const onChange = jest.fn();
render(<NumberInput {...defaultProps} onChange={onChange} />);
const input = screen.getByPlaceholderText('Enter value');
fireEvent.change(input, { target: { value: '60' } });
expect(onChange).toHaveBeenCalled();
});
@@ -22,9 +22,10 @@ import {
screen,
userEvent,
waitFor,
createStore,
} from 'spec/helpers/testing-library';
import reducerIndex from 'spec/helpers/reducerIndex';
import { FeatureFlag, VizType, isFeatureEnabled } from '@superset-ui/core';
import * as actions from 'src/features/reports/ReportModal/actions';
import ReportModal from '.';
const REPORT_ENDPOINT = 'glob:*/api/v1/report*';
@@ -56,115 +57,260 @@ jest.mock('@superset-ui/core', () => ({
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('Email Report Modal', () => {
beforeEach(() => {
mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.AlertReports,
);
render(<ReportModal {...defaultProps} />, { useRedux: true });
});
test('inputs respond correctly', () => {
// ----- Report name textbox
// Initial value
const reportNameTextbox = screen.getByTestId('report-name-test');
expect(reportNameTextbox).toHaveDisplayValue('Weekly Report');
// Type in the textbox and assert that it worked
userEvent.clear(reportNameTextbox);
userEvent.type(reportNameTextbox, 'Report name text test');
expect(reportNameTextbox).toHaveDisplayValue('Report name text test');
// ----- Report description textbox
// Initial value
const reportDescriptionTextbox = screen.getByTestId(
'report-description-test',
);
expect(reportDescriptionTextbox).toHaveDisplayValue('');
// Type in the textbox and assert that it worked
userEvent.type(reportDescriptionTextbox, 'Report description text test');
expect(reportDescriptionTextbox).toHaveDisplayValue(
'Report description text test',
);
// ----- Crontab
const crontabInputs = screen.getAllByRole('combobox');
expect(crontabInputs).toHaveLength(5);
beforeEach(() => {
mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.AlertReports,
);
});
test('inputs respond correctly', () => {
render(<ReportModal {...defaultProps} />, { useRedux: true });
// ----- Report name textbox
const reportNameTextbox = screen.getByTestId('report-name-test');
expect(reportNameTextbox).toHaveDisplayValue('Weekly Report');
userEvent.clear(reportNameTextbox);
userEvent.type(reportNameTextbox, 'Report name text test');
expect(reportNameTextbox).toHaveDisplayValue('Report name text test');
// ----- Report description textbox
const reportDescriptionTextbox = screen.getByTestId(
'report-description-test',
);
expect(reportDescriptionTextbox).toHaveDisplayValue('');
userEvent.type(reportDescriptionTextbox, 'Report description text test');
expect(reportDescriptionTextbox).toHaveDisplayValue(
'Report description text test',
);
// ----- Crontab
const crontabInputs = screen.getAllByRole('combobox');
expect(crontabInputs).toHaveLength(5);
});
test('does not allow user to create a report without a name', () => {
render(<ReportModal {...defaultProps} />, { useRedux: true });
const reportNameTextbox = screen.getByTestId('report-name-test');
const addButton = screen.getByRole('button', { name: /add/i });
expect(reportNameTextbox).toHaveDisplayValue('Weekly Report');
expect(addButton).toBeEnabled();
userEvent.clear(reportNameTextbox);
expect(reportNameTextbox).toHaveDisplayValue('');
expect(addButton).toBeDisabled();
});
test('creates a new email report via modal Add button', async () => {
fetchMock.post(
REPORT_ENDPOINT,
{ id: 1, result: {} },
{ name: 'post-report' },
);
render(<ReportModal {...defaultProps} />, { useRedux: true });
const addButton = screen.getByRole('button', { name: /add/i });
await waitFor(() => userEvent.click(addButton));
// Verify exactly one POST from the modal submit path
await waitFor(() => {
const postCalls = fetchMock.callHistory.calls('post-report');
expect(postCalls).toHaveLength(1);
});
const postCalls = fetchMock.callHistory.calls('post-report');
const body = JSON.parse(postCalls[0].options.body as string);
expect(body.name).toBe('Weekly Report');
expect(body.type).toBe('Report');
expect(body.creation_method).toBe('dashboards');
expect(body.crontab).toBeDefined();
expect(body.recipients).toBeDefined();
expect(body.recipients[0].type).toBe('Email');
fetchMock.removeRoute('post-report');
});
test('text-based chart hides screenshot width and shows message content', () => {
// Table is text-based: should show message content but hide custom width
const textChartProps = {
...defaultProps,
dashboardId: undefined,
chart: { id: 1, sliceFormData: { viz_type: VizType.Table } },
chartName: 'My Table Chart',
creationMethod: 'charts' as const,
};
render(<ReportModal {...textChartProps} />, { useRedux: true });
// Message content section should be visible
expect(screen.getByText('Message content')).toBeInTheDocument();
expect(screen.getByText(/Text embedded in email/i)).toBeInTheDocument();
// Screenshot width should NOT be visible for text-based chart
expect(screen.queryByText('Screenshot width')).not.toBeInTheDocument();
});
test('non-text chart shows screenshot width and message content', () => {
const lineChartProps = {
...defaultProps,
dashboardId: undefined,
chart: { id: 1, sliceFormData: { viz_type: VizType.Line } },
chartName: 'My Line Chart',
creationMethod: 'charts' as const,
};
render(<ReportModal {...lineChartProps} />, { useRedux: true });
test('does not allow user to create a report without a name', () => {
// Grab name textbox and add button
const reportNameTextbox = screen.getByTestId('report-name-test');
const addButton = screen.getByRole('button', { name: /add/i });
// Both message content and screenshot width should be visible
expect(screen.getByText('Message content')).toBeInTheDocument();
expect(screen.getByText('Screenshot width')).toBeInTheDocument();
});
// Add button should be enabled while name textbox has text
expect(reportNameTextbox).toHaveDisplayValue('Weekly Report');
expect(addButton).toBeEnabled();
test('dashboard report hides message content section', () => {
const dashboardProps = {
...defaultProps,
chart: undefined,
dashboardName: 'My Dashboard',
};
render(<ReportModal {...dashboardProps} />, { useRedux: true });
// Clear the text from the name textbox
userEvent.clear(reportNameTextbox);
// Message content (radio group) should NOT be visible for dashboard
expect(screen.queryByText('Message content')).not.toBeInTheDocument();
// Screenshot width SHOULD be visible
expect(screen.getByText('Screenshot width')).toBeInTheDocument();
});
// Add button should now be disabled, blocking user from creation
expect(reportNameTextbox).toHaveDisplayValue('');
expect(addButton).toBeDisabled();
test('renders edit mode when report exists in store', () => {
const existingReport = {
id: 42,
name: 'Existing Dashboard Report',
description: 'An existing report',
crontab: '0 9 * * 1',
creation_method: 'dashboards',
report_format: 'PNG',
timezone: 'America/New_York',
active: true,
type: 'Report',
dashboard: 1,
owners: [1],
recipients: [
{
recipient_config_json: { target: 'test@test.com' },
type: 'Email',
},
],
};
const store = createStore(
{
reports: {
dashboards: { 1: existingReport },
},
},
reducerIndex,
);
render(<ReportModal {...defaultProps} />, { useRedux: true, store });
// Edit mode title
expect(screen.getByText('Edit email report')).toBeInTheDocument();
// Report name populated from store
expect(screen.getByTestId('report-name-test')).toHaveDisplayValue(
'Existing Dashboard Report',
);
// Save button instead of Add
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
test('edit mode dispatches editReport via PUT on save', async () => {
const existingReport = {
id: 42,
name: 'Existing Report',
description: '',
crontab: '0 12 * * 1',
creation_method: 'dashboards',
report_format: 'PNG',
timezone: 'America/New_York',
active: true,
type: 'Report',
dashboard: 1,
owners: [1],
recipients: [
{
recipient_config_json: { target: 'test@test.com' },
type: 'Email',
},
],
};
const store = createStore(
{
reports: {
dashboards: { 1: existingReport },
},
},
reducerIndex,
);
fetchMock.put(
'glob:*/api/v1/report/42',
{ id: 42, result: {} },
{
name: 'put-report-42',
},
);
render(<ReportModal {...defaultProps} />, { useRedux: true, store });
expect(screen.getByText('Edit email report')).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: /save/i });
await waitFor(() => userEvent.click(saveButton));
await waitFor(() => {
const calls = fetchMock.callHistory.calls('put-report-42');
expect(calls.length).toBeGreaterThan(0);
});
const calls = fetchMock.callHistory.calls('put-report-42');
const body = JSON.parse(calls[calls.length - 1].options.body as string);
// Pin critical payload fields to catch regressions
expect(body.type).toBe('Report');
expect(body.name).toBe('Existing Report');
expect(body.crontab).toBe('0 12 * * 1');
expect(body.report_format).toBe('PNG');
expect(body.dashboard).toBe(1);
expect(body.recipients).toBeDefined();
expect(body.recipients[0].type).toBe('Email');
fetchMock.removeRoute('put-report-42');
});
test('submit failure dispatches danger toast and keeps modal open', async () => {
fetchMock.post(REPORT_ENDPOINT, 500, { name: 'post-fail' });
const onHide = jest.fn();
const store = createStore({}, reducerIndex);
render(<ReportModal {...defaultProps} onHide={onHide} />, {
useRedux: true,
store,
});
const addButton = screen.getByRole('button', { name: /add/i });
await waitFor(() => userEvent.click(addButton));
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('Email Report Modal', () => {
let dispatch: any;
beforeEach(async () => {
dispatch = jest.fn();
});
test('creates a new email report', async () => {
// ---------- Render/value setup ----------
const reportValues = {
id: 1,
result: {
active: true,
creation_method: 'dashboards',
crontab: '0 12 * * 1',
dashboard: 1,
name: 'Weekly Report',
owners: [1],
recipients: [
{
recipient_config_json: {
target: 'test@test.com',
},
type: 'Email',
},
],
type: 'Report',
},
};
// This is needed to structure the reportValues to match the fetchMock return
const stringyReportValues = `{"id":1,"result":{"active":true,"creation_method":"dashboards","crontab":"0 12 * * 1","dashboard":${1},"name":"Weekly Report","owners":[${1}],"recipients":[{"recipient_config_json":{"target":"test@test.com"},"type":"Email"}],"type":"Report"}}`;
// Watch for report POST
fetchMock.post(REPORT_ENDPOINT, reportValues);
// Click "Add" button to create a new email report
const addButton = screen.getByRole('button', { name: /add/i });
await waitFor(() => userEvent.click(addButton));
// Mock addReport from Redux
const makeRequest = () => {
const request = actions.addReport(reportValues);
return request(dispatch);
};
await makeRequest();
// 🐞 ----- There are 2 POST calls at this point ----- 🐞
// addReport's mocked POST return should match the mocked values
expect(fetchMock.callHistory.lastCall()?.options?.body).toEqual(
stringyReportValues,
);
expect(dispatch).toHaveBeenCalledTimes(2);
const reportCalls = fetchMock.callHistory.calls(REPORT_ENDPOINT);
expect(reportCalls).toHaveLength(2);
});
// The addReport action catches 500 errors, dispatches a danger toast, and re-throws
await waitFor(() => {
const toasts = (store.getState() as any).messageToasts;
expect(toasts.length).toBeGreaterThan(0);
expect(
toasts.some((t: { text: string }) =>
t.text.includes('Failed to create report'),
),
).toBe(true);
});
// Modal stays open — onHide should NOT have been called
expect(onHide).not.toHaveBeenCalled();
expect(screen.getByText('Schedule a new email report')).toBeInTheDocument();
fetchMock.removeRoute('post-fail');
});
@@ -0,0 +1,178 @@
/**
* 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 {
addReport,
editReport,
deleteActiveReport,
fetchUISpecificReport,
ADD_REPORT,
EDIT_REPORT,
DELETE_REPORT,
SET_REPORT,
} from './actions';
const REPORT_ENDPOINT = 'glob:*/api/v1/report/*';
const REPORT_POST_ENDPOINT = 'glob:*/api/v1/report/';
afterEach(() => {
fetchMock.clearHistory().removeRoutes();
});
test('addReport dispatches ADD_REPORT and success toast on success', async () => {
const jsonResponse = { id: 1, result: { name: 'New Report' } };
fetchMock.post(REPORT_POST_ENDPOINT, jsonResponse);
const dispatch = jest.fn();
await addReport({ name: 'New Report' })(dispatch);
const types = dispatch.mock.calls.map(([action]: any) => action.type);
expect(types).toContain(ADD_REPORT);
expect(types).toContain('ADD_TOAST');
const toastAction = dispatch.mock.calls.find(
([a]: any) => a.type === 'ADD_TOAST',
)?.[0];
expect(toastAction.payload.toastType).toBe('SUCCESS_TOAST');
});
test('addReport dispatches danger toast on failure and rejects', async () => {
fetchMock.post(REPORT_POST_ENDPOINT, 500);
const dispatch = jest.fn();
await expect(
addReport({ name: 'Bad Report' })(dispatch),
).rejects.toBeDefined();
const types = dispatch.mock.calls.map(([action]: any) => action.type);
expect(types).not.toContain(ADD_REPORT);
expect(types).toContain('ADD_TOAST');
const toastAction = dispatch.mock.calls.find(
([a]: any) => a.type === 'ADD_TOAST',
)?.[0];
expect(toastAction.payload.toastType).toBe('DANGER_TOAST');
});
test('editReport dispatches EDIT_REPORT and success toast on success', async () => {
const jsonResponse = { id: 5, result: { name: 'Updated Report' } };
fetchMock.put(REPORT_ENDPOINT, jsonResponse);
const dispatch = jest.fn();
await editReport(5, { name: 'Updated Report' })(dispatch);
const types = dispatch.mock.calls.map(([action]: any) => action.type);
expect(types).toContain(EDIT_REPORT);
expect(types).toContain('ADD_TOAST');
const toastAction = dispatch.mock.calls.find(
([a]: any) => a.type === 'ADD_TOAST',
)?.[0];
expect(toastAction.payload.toastType).toBe('SUCCESS_TOAST');
});
test('editReport dispatches danger toast on failure and rejects', async () => {
fetchMock.put(REPORT_ENDPOINT, 500);
const dispatch = jest.fn();
await expect(
editReport(5, { name: 'Bad Update' })(dispatch),
).rejects.toBeDefined();
const types = dispatch.mock.calls.map(([action]: any) => action.type);
expect(types).not.toContain(EDIT_REPORT);
const toastAction = dispatch.mock.calls.find(
([a]: any) => a.type === 'ADD_TOAST',
)?.[0];
expect(toastAction.payload.toastType).toBe('DANGER_TOAST');
});
test('deleteActiveReport dispatches DELETE_REPORT and success toast on success', async () => {
fetchMock.delete(REPORT_ENDPOINT, {});
const dispatch = jest.fn();
const report = {
id: 10,
name: 'To Delete',
creation_method: 'dashboards',
dashboard: 1,
};
await deleteActiveReport(report)(dispatch);
const types = dispatch.mock.calls.map(([action]: any) => action.type);
expect(types).toContain(DELETE_REPORT);
expect(types).toContain('ADD_TOAST');
const toastAction = dispatch.mock.calls.find(
([a]: any) => a.type === 'ADD_TOAST',
)?.[0];
expect(toastAction.payload.toastType).toBe('SUCCESS_TOAST');
});
test('deleteActiveReport dispatches danger toast on failure', async () => {
fetchMock.delete(REPORT_ENDPOINT, 500);
const dispatch = jest.fn();
const report = { id: 10, name: 'To Delete' };
await deleteActiveReport(report)(dispatch);
const types = dispatch.mock.calls.map(([action]: any) => action.type);
expect(types).not.toContain(DELETE_REPORT);
const toastAction = dispatch.mock.calls.find(
([a]: any) => a.type === 'ADD_TOAST',
)?.[0];
expect(toastAction.payload.toastType).toBe('DANGER_TOAST');
});
test('fetchUISpecificReport dispatches SET_REPORT on success', async () => {
const jsonResponse = { result: [{ id: 1, name: 'Dashboard Report' }] };
fetchMock.get('glob:*/api/v1/report/?q=*', jsonResponse);
const dispatch = jest.fn();
await fetchUISpecificReport({
userId: 1,
filterField: 'dashboard_id',
creationMethod: 'dashboards',
resourceId: 42,
})(dispatch);
const setAction = dispatch.mock.calls.find(
([a]: any) => a.type === SET_REPORT,
)?.[0];
expect(setAction).toBeDefined();
expect(setAction.resourceId).toBe(42);
expect(setAction.creationMethod).toBe('dashboards');
expect(setAction.filterField).toBe('dashboard_id');
});
test('fetchUISpecificReport dispatches danger toast on failure', async () => {
fetchMock.get('glob:*/api/v1/report/?q=*', 500);
const dispatch = jest.fn();
await fetchUISpecificReport({
userId: 1,
filterField: 'chart_id',
creationMethod: 'charts',
resourceId: 10,
})(dispatch);
const types = dispatch.mock.calls.map(([action]: any) => action.type);
expect(types).not.toContain(SET_REPORT);
expect(types).toContain('ADD_TOAST');
const toastAction = dispatch.mock.calls.find(
([a]: any) => a.type === 'ADD_TOAST',
)?.[0];
expect(toastAction.payload.toastType).toBe('DANGER_TOAST');
});
@@ -169,8 +169,9 @@ export const addReport =
dispatch({ type: ADD_REPORT, json } as AddReportAction);
dispatch(addSuccessToast(t('The report has been created')));
})
.catch(() => {
.catch(err => {
dispatch(addDangerToast(t('Failed to create report')));
throw err;
});
export const EDIT_REPORT = 'EDIT_REPORT' as const;
@@ -191,8 +192,9 @@ export const editReport =
dispatch({ type: EDIT_REPORT, json } as EditReportAction);
dispatch(addSuccessToast(t('Report updated')));
})
.catch(() => {
.catch(err => {
dispatch(addDangerToast(t('Failed to update report')));
throw err;
});
export function toggleActive(report: ReportObject, isActive: boolean) {
@@ -0,0 +1,214 @@
/**
* 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 reportsReducer, { ReportsState } from './reducer';
import {
SET_REPORT,
ADD_REPORT,
EDIT_REPORT,
DELETE_REPORT,
SetReportAction,
AddReportAction,
EditReportAction,
DeleteReportAction,
} from './actions';
import { ReportObject } from 'src/features/reports/types';
const makeReport = (overrides: Partial<ReportObject> = {}): ReportObject => ({
active: true,
crontab: '0 12 * * 1',
name: 'Test Report',
owners: [1],
recipients: [
{
recipient_config_json: { target: 'a@b.com', ccTarget: '', bccTarget: '' },
type: 'Email',
},
],
report_format: 'PNG',
timezone: 'UTC',
type: 'Report',
validator_config_json: null,
validator_type: '',
working_timeout: 3600,
log_retention: 90,
creation_method: 'dashboards',
force_screenshot: false,
...overrides,
});
test('SET_REPORT stores report keyed by resourceId under creationMethod', () => {
const report = makeReport({ id: 5, dashboard: 10 });
const action: SetReportAction = {
type: SET_REPORT,
report: { result: [report] },
resourceId: 10,
creationMethod: 'dashboards',
filterField: 'dashboard_id',
};
const result = reportsReducer({}, action);
expect(result.dashboards?.[10]).toEqual(report);
});
test('SET_REPORT removes entry when API returns empty result', () => {
const initial: ReportsState = {
dashboards: { 10: makeReport({ id: 5, dashboard: 10 }) },
};
const action: SetReportAction = {
type: SET_REPORT,
report: { result: [] },
resourceId: 10,
creationMethod: 'dashboards',
filterField: 'dashboard_id',
};
const result = reportsReducer(initial, action);
expect(result.dashboards?.[10]).toBeUndefined();
});
test('SET_REPORT uses chart property when filterField is chart_id', () => {
const report = makeReport({
id: 7,
chart: 42,
creation_method: 'charts',
});
const action: SetReportAction = {
type: SET_REPORT,
report: { result: [report] },
resourceId: 42,
creationMethod: 'charts',
filterField: 'chart_id',
};
const result = reportsReducer({}, action);
expect(result.charts?.[42]).toEqual(report);
});
test('ADD_REPORT keys dashboard report by dashboard id', () => {
const action: AddReportAction = {
type: ADD_REPORT,
json: {
id: 1,
result: { dashboard: 10, creation_method: 'dashboards' },
},
};
const result = reportsReducer({}, action);
expect(result.dashboards?.[10]).toMatchObject({ id: 1, dashboard: 10 });
});
test('ADD_REPORT keys alerts_reports report by report id', () => {
const action: AddReportAction = {
type: ADD_REPORT,
json: {
id: 99,
result: { creation_method: 'alerts_reports' },
},
};
const result = reportsReducer({}, action);
expect(result.alerts_reports?.[99]).toMatchObject({ id: 99 });
});
test('ADD_REPORT returns unchanged state when key is undefined', () => {
const initial: ReportsState = { dashboards: {} };
const action: AddReportAction = {
type: ADD_REPORT,
json: {
id: 1,
result: { creation_method: 'dashboards' },
// no dashboard or chart field → key is undefined
},
};
const result = reportsReducer(initial, action);
expect(result).toBe(initial);
});
test('EDIT_REPORT replaces existing report at same key', () => {
const initial: ReportsState = {
dashboards: {
10: makeReport({ id: 1, dashboard: 10, name: 'Old Name' }),
},
};
const action: EditReportAction = {
type: EDIT_REPORT,
json: {
id: 1,
result: {
dashboard: 10,
creation_method: 'dashboards',
name: 'New Name',
},
},
};
const result = reportsReducer(initial, action);
expect(result.dashboards?.[10]?.name).toBe('New Name');
});
test('DELETE_REPORT removes report from state', () => {
const report = makeReport({
id: 5,
dashboard: 10,
creation_method: 'dashboards',
});
const initial: ReportsState = { dashboards: { 10: report } };
const action: DeleteReportAction = {
type: DELETE_REPORT,
report: {
id: 5,
dashboard: 10,
creation_method: 'dashboards',
},
};
const result = reportsReducer(initial, action);
expect(result.dashboards?.[10]).toBeUndefined();
});
test('DELETE_REPORT for alerts_reports keys by report id', () => {
const report = makeReport({ id: 99, creation_method: 'alerts_reports' });
const initial: ReportsState = { alerts_reports: { 99: report } };
const action: DeleteReportAction = {
type: DELETE_REPORT,
report: { id: 99, creation_method: 'alerts_reports' },
};
const result = reportsReducer(initial, action);
expect(result.alerts_reports?.[99]).toBeUndefined();
});
test('unknown action type returns state unchanged', () => {
const initial: ReportsState = { dashboards: { 1: makeReport({ id: 1 }) } };
const action = { type: 'UNKNOWN_ACTION' } as any;
const result = reportsReducer(initial, action);
expect(result).toBe(initial);
});
@@ -16,271 +16,469 @@
* specific language governing permissions and limitations
* under the License.
*/
import type React from 'react';
import fetchMock from 'fetch-mock';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {
render,
screen,
fireEvent,
waitFor,
createStore,
} from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
import React from 'react';
import AlertListComponent from 'src/pages/AlertReportList';
// Cast to accept partial mock props in tests
jest.setTimeout(30000);
const AlertList = AlertListComponent as unknown as React.FC<
Record<string, any>
>;
const mockStore = configureStore([thunk]);
const store = mockStore({});
// -- Mock data (IDs start at 1 to avoid the `if (data?.id)` falsy guard) --
const alertsEndpoint = 'glob:*/api/v1/report/?*';
const alertEndpoint = 'glob:*/api/v1/report/*';
const alertsInfoEndpoint = 'glob:*/api/v1/report/_info*';
const alertsCreatedByEndpoint = 'glob:*/api/v1/report/related/created_by*';
const mockAlerts = [
{
id: 1,
name: 'Weekly Sales Alert',
active: true,
last_state: 'Success',
type: 'Alert',
owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }],
recipients: [{ id: 1, type: 'Email' }],
changed_by: { id: 1, first_name: 'Admin', last_name: 'User' },
changed_on_delta_humanized: '1 day ago',
created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
created_on: new Date().toISOString(),
last_eval_dttm: Date.now(),
crontab: '0 9 * * 1',
crontab_humanized: 'Every Monday at 09:00',
timezone: 'UTC',
},
{
id: 2,
name: 'Daily Error Alert',
active: true,
last_state: 'Error',
type: 'Alert',
owners: [{ id: 2, first_name: 'Data', last_name: 'Analyst' }],
recipients: [{ id: 2, type: 'Slack' }],
changed_by: { id: 2, first_name: 'Data', last_name: 'Analyst' },
changed_on_delta_humanized: '2 days ago',
created_by: { id: 2, first_name: 'Data', last_name: 'Analyst' },
created_on: new Date().toISOString(),
last_eval_dttm: Date.now(),
crontab: '0 8 * * *',
crontab_humanized: 'Every day at 08:00',
timezone: 'US/Pacific',
},
{
id: 3,
name: 'Monthly Revenue Alert',
active: false,
last_state: 'Working',
type: 'Alert',
owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }],
recipients: [{ id: 3, type: 'Email' }],
changed_by: { id: 1, first_name: 'Admin', last_name: 'User' },
changed_on_delta_humanized: '5 days ago',
created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
created_on: new Date().toISOString(),
last_eval_dttm: Date.now(),
crontab: '0 0 1 * *',
crontab_humanized: 'First day of the month',
timezone: 'UTC',
},
];
const mockalerts = Array.from({ length: 3 }, (_, i) => ({
active: true,
changed_by: {
first_name: `user ${i}`,
id: i,
const mockReports = [
{
id: 10,
name: 'Weekly Dashboard Report',
active: true,
last_state: 'Success',
type: 'Report',
owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }],
recipients: [{ id: 10, type: 'Email' }],
changed_by: { id: 1, first_name: 'Admin', last_name: 'User' },
changed_on_delta_humanized: '1 day ago',
created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
created_on: new Date().toISOString(),
last_eval_dttm: Date.now(),
crontab: '0 9 * * 1',
crontab_humanized: 'Every Monday at 09:00',
timezone: 'UTC',
},
changed_on_delta_humanized: `${i} day(s) ago`,
created_by: {
first_name: `user ${i}`,
id: i,
{
id: 11,
name: 'Monthly KPI Report',
active: false,
last_state: 'Not triggered',
type: 'Report',
owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }],
recipients: [{ id: 11, type: 'Slack' }],
changed_by: { id: 1, first_name: 'Admin', last_name: 'User' },
changed_on_delta_humanized: '3 days ago',
created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
created_on: new Date().toISOString(),
last_eval_dttm: Date.now(),
crontab: '0 0 1 * *',
crontab_humanized: 'First day of the month',
timezone: 'UTC',
},
created_on: new Date().toISOString,
id: i,
last_eval_dttm: Date.now(),
last_state: 'ok',
name: `alert ${i} `,
owners: [{ id: 1 }],
recipients: [
{
id: `${i}`,
type: 'email',
},
],
type: 'alert',
}));
];
const mockUser = {
userId: 1,
firstName: 'user 1',
lastName: 'lastname',
firstName: 'Admin',
lastName: 'User',
};
fetchMock.get(alertsEndpoint, {
ids: [2, 0, 1],
result: mockalerts,
count: 3,
});
fetchMock.get(alertsInfoEndpoint, {
permissions: ['can_write'],
});
fetchMock.get(alertsCreatedByEndpoint, { result: [] });
fetchMock.put(alertEndpoint, { ...mockalerts[0], active: false });
fetchMock.put(alertsEndpoint, { ...mockalerts[0], active: false });
fetchMock.delete(alertEndpoint, {});
fetchMock.delete(alertsEndpoint, {});
// -- API endpoints (named for cleanup) --
const renderAlertList = (props = {}) =>
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<AlertList user={mockUser} {...props} />
</QueryParamProvider>
</MemoryRouter>,
{
useRedux: true,
store,
const ENDPOINTS = {
LIST: 'glob:*/api/v1/report/?*',
INFO: 'glob:*/api/v1/report/_info*',
SINGLE: 'glob:*/api/v1/report/*',
CREATED_BY: 'glob:*/api/v1/report/related/created_by*',
OWNERS: 'glob:*/api/v1/report/related/owners*',
CHANGED_BY: 'glob:*/api/v1/report/related/changed_by*',
};
// -- Render helper --
const renderAlertList = (props: Record<string, any> = {}) => {
const store = createStore();
return render(
<Provider store={store}>
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<AlertList user={mockUser} {...props} />
</QueryParamProvider>
</MemoryRouter>
</Provider>,
);
};
// -- Dynamic list endpoint: returns alerts or reports based on URL filter --
const setupMocks = (
permissions: string[] = ['can_read', 'can_write'],
listData?: typeof mockAlerts,
) => {
fetchMock.get(ENDPOINTS.INFO, { permissions }, { name: 'info' });
fetchMock.get(
ENDPOINTS.LIST,
({ url }: any) => {
if (listData) {
return {
result: listData,
count: listData.length,
ids: listData.map(a => a.id),
};
}
const data = url.includes('value:Report') ? mockReports : mockAlerts;
return { result: data, count: data.length, ids: data.map(a => a.id) };
},
{ name: 'list' },
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('AlertList', () => {
beforeEach(() => {
fetchMock.clearHistory();
});
fetchMock.get(ENDPOINTS.CREATED_BY, { result: [] }, { name: 'created-by' });
test('renders', async () => {
renderAlertList();
expect(await screen.findByText('Alerts & reports')).toBeInTheDocument();
});
fetchMock.get(ENDPOINTS.OWNERS, { result: [], count: 0 }, { name: 'owners' });
test('renders a SubMenu', async () => {
renderAlertList();
expect(await screen.findByRole('navigation')).toBeInTheDocument();
});
fetchMock.get(
ENDPOINTS.CHANGED_BY,
{ result: [], count: 0 },
{ name: 'changed-by' },
);
test('renders a ListView', async () => {
renderAlertList();
expect(await screen.findByTestId('alerts-list-view')).toBeInTheDocument();
});
fetchMock.put(
ENDPOINTS.SINGLE,
{ result: { ...mockAlerts[0], active: false } },
{ name: 'put-alert' },
);
test('renders switches', async () => {
renderAlertList();
// Wait for the list to load first
await screen.findByTestId('alerts-list-view');
const switches = await screen.findAllByRole('switch');
expect(switches).toHaveLength(3);
});
fetchMock.delete(ENDPOINTS.SINGLE, {}, { name: 'delete-alert' });
test('deletes', async () => {
renderAlertList();
fetchMock.delete(
ENDPOINTS.LIST,
{ message: 'Deleted' },
{ name: 'delete-bulk' },
);
};
// Wait for list to load
await screen.findByTestId('alerts-list-view');
// -- Setup / teardown --
// Find and click first delete button
const deleteButtons = await screen.findAllByTestId('delete-action');
fireEvent.click(deleteButtons[0]);
// Wait for modal to appear and find the delete input
const deleteInput = await screen.findByTestId('delete-modal-input');
fireEvent.change(deleteInput, { target: { value: 'DELETE' } });
// Click confirm button
const confirmButton = await screen.findByTestId('modal-confirm-button');
fireEvent.click(confirmButton);
// Wait for delete request
await waitFor(() => {
expect(
fetchMock.callHistory.calls(/report\/0/, { method: 'DELETE' }),
).toHaveLength(1);
});
}, 15000);
test('shows/hides bulk actions when bulk actions is clicked', async () => {
renderAlertList();
// Wait for list to load and initial state
await screen.findByTestId('alerts-list-view');
expect(
screen.queryByTestId('bulk-select-controls'),
).not.toBeInTheDocument();
// Click bulk select toggle
const bulkSelectButton = await screen.findByTestId('bulk-select-toggle');
fireEvent.click(bulkSelectButton);
// Verify bulk select controls appear
expect(
await screen.findByTestId('bulk-select-controls'),
).toBeInTheDocument();
}, 15000);
test('hides bulk actions when switch between alert and report list', async () => {
// Start with alert list
renderAlertList();
// Wait for list to load
await screen.findByTestId('alerts-list-view');
// Click bulk select to show controls
const bulkSelectButton = await screen.findByTestId('bulk-select-toggle');
fireEvent.click(bulkSelectButton);
// Verify bulk select controls appear
expect(
await screen.findByTestId('bulk-select-controls'),
).toBeInTheDocument();
// Verify alert tab is active
const alertTab = await screen.findByTestId('alert-list');
expect(alertTab).toHaveClass('active');
const reportTab = screen.getByTestId('report-list');
expect(reportTab).not.toHaveClass('active');
// Switch to report list
renderAlertList({ isReportEnabled: true });
// Wait for report list API call and tab states to update
await waitFor(async () => {
// Check API call
const calls = fetchMock.callHistory.calls(/report\/\?q/);
const hasReportCall = calls.some(call =>
call.url.includes('filters:!((col:type,opr:eq,value:Report))'),
);
// Check tab states
const reportTabs = screen.getAllByTestId('report-list');
const alertTabs = screen.getAllByTestId('alert-list');
const hasActiveReport = reportTabs.some(tab =>
tab.classList.contains('active'),
);
const hasNoActiveAlert = alertTabs.every(
tab => !tab.classList.contains('active'),
);
return hasReportCall && hasActiveReport && hasNoActiveAlert;
});
// Click bulk select toggle again to hide controls
const bulkSelectButtons =
await screen.findAllByTestId('bulk-select-toggle');
fireEvent.click(bulkSelectButtons[0]);
// Verify final state
await waitFor(() => {
expect(
screen.queryByTestId('bulk-select-controls'),
).not.toBeInTheDocument();
});
// Verify correct API call was made
const reportCalls = fetchMock.callHistory.calls(/report\/\?q/);
const lastReportCall = reportCalls[reportCalls.length - 1].url;
expect(lastReportCall).toContain(
'filters:!((col:type,opr:eq,value:Report))',
);
}, 15000);
test('renders listview table correctly', async () => {
renderAlertList();
await screen.findByTestId('alerts-list-view');
const table = await screen.findByTestId('listview-table');
expect(table).toBeInTheDocument();
expect(table).toBeVisible();
}, 15000);
test('renders correct column headers for alerts', async () => {
renderAlertList();
await screen.findByTestId('alerts-list-view');
expect(screen.getByTitle('Last run')).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: /name/i }),
).toBeInTheDocument();
expect(screen.getByTitle('Schedule')).toBeInTheDocument();
expect(screen.getByTitle('Notification method')).toBeInTheDocument();
expect(screen.getByTitle('Owners')).toBeInTheDocument();
expect(screen.getByTitle('Last modified')).toBeInTheDocument();
expect(screen.getByTitle('Active')).toBeInTheDocument();
expect(screen.getByTitle('Actions')).toBeInTheDocument();
}, 15000);
test('renders correct column headers for reports', async () => {
renderAlertList({ isReportEnabled: true });
await screen.findByTestId('alerts-list-view');
expect(screen.getByTitle('Last run')).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: /name/i }),
).toBeInTheDocument();
expect(screen.getByTitle('Schedule')).toBeInTheDocument();
expect(screen.getByTitle('Notification method')).toBeInTheDocument();
expect(screen.getByTitle('Owners')).toBeInTheDocument();
expect(screen.getByTitle('Last modified')).toBeInTheDocument();
expect(screen.getByTitle('Active')).toBeInTheDocument();
expect(screen.getByTitle('Actions')).toBeInTheDocument();
}, 15000);
beforeEach(() => {
fetchMock.removeRoutes().clearHistory();
setupMocks();
});
afterEach(() => {
fetchMock.removeRoutes().clearHistory();
});
// -- Tests --
test('loads rows from API and renders alert names, status, and actions', async () => {
renderAlertList();
// All 3 alert names appear
await screen.findByText('Weekly Sales Alert');
expect(screen.getByText('Daily Error Alert')).toBeInTheDocument();
expect(screen.getByText('Monthly Revenue Alert')).toBeInTheDocument();
// Active switches rendered for each row
const switches = screen.getAllByRole('switch');
expect(switches).toHaveLength(3);
// Delete actions present for owned alerts (userId=1 owns alerts 1 and 3)
const deleteButtons = screen.getAllByTestId('delete-action');
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
// Column headers
expect(screen.getByTitle('Last run')).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: /name/i }),
).toBeInTheDocument();
expect(screen.getByTitle('Schedule')).toBeInTheDocument();
expect(screen.getByTitle('Active')).toBeInTheDocument();
expect(screen.getByTitle('Actions')).toBeInTheDocument();
// API was called with Alert filter
const listCalls = fetchMock.callHistory.calls('list');
expect(listCalls.length).toBeGreaterThanOrEqual(1);
expect(listCalls[0].url).toContain('value:Alert');
});
test('toggle active sends PUT and updates switch state', async () => {
renderAlertList();
await screen.findByText('Weekly Sales Alert');
const switches = screen.getAllByRole('switch');
// First switch is for alert id=1 (owned by user, active: true)
expect(switches[0]).toBeChecked();
fireEvent.click(switches[0]);
// PUT called with active=false
await waitFor(() => {
const putCalls = fetchMock.callHistory.calls('put-alert');
expect(putCalls).toHaveLength(1);
});
const putCalls = fetchMock.callHistory.calls('put-alert');
const body = JSON.parse(putCalls[0].options.body as string);
expect(body.active).toBe(false);
expect(putCalls[0].url).toContain('/report/1');
});
test('toggle active rolls back on failed update', async () => {
renderAlertList();
await screen.findByText('Weekly Sales Alert');
// Replace PUT with 500 error
fetchMock.removeRoute('put-alert');
fetchMock.put(ENDPOINTS.SINGLE, 500, { name: 'put-fail' });
const switches = screen.getAllByRole('switch');
expect(switches[0]).toBeChecked();
fireEvent.click(switches[0]);
// PUT was attempted
await waitFor(() => {
expect(fetchMock.callHistory.calls('put-fail')).toHaveLength(1);
});
// Switch rolls back to checked (server rejected)
await waitFor(() => {
expect(switches[0]).toBeChecked();
});
});
test('switching to Reports refetches and renders only report rows', async () => {
// Render alerts mode first
const { unmount } = renderAlertList();
await screen.findByText('Weekly Sales Alert');
unmount();
// Render reports mode
fetchMock.clearHistory();
renderAlertList({ isReportEnabled: true });
await screen.findByText('Weekly Dashboard Report');
expect(screen.getByText('Monthly KPI Report')).toBeInTheDocument();
// Alert names should not appear
expect(screen.queryByText('Weekly Sales Alert')).not.toBeInTheDocument();
expect(screen.queryByText('Daily Error Alert')).not.toBeInTheDocument();
// API called with Report filter
const listCalls = fetchMock.callHistory.calls('list');
expect(listCalls.length).toBeGreaterThanOrEqual(1);
const reportCall = listCalls.find((c: any) => c.url.includes('value:Report'));
expect(reportCall).toBeDefined();
});
test('delete removes row after confirmation', async () => {
// Track deletions so the GET mock reflects them on refetch
const deletedIds = new Set<number>();
fetchMock.removeRoute('list');
fetchMock.get(
ENDPOINTS.LIST,
(_callLog: any) => {
const remaining = mockAlerts.filter(a => !deletedIds.has(a.id));
return {
result: remaining,
count: remaining.length,
ids: remaining.map(a => a.id),
};
},
{ name: 'list' },
);
// Override DELETE to mark the alert as deleted before returning
fetchMock.removeRoute('delete-alert');
fetchMock.delete(
ENDPOINTS.SINGLE,
({ url }: any) => {
const match = url.match(/\/report\/(\d+)/);
if (match) deletedIds.add(Number(match[1]));
return {};
},
{ name: 'delete-alert' },
);
renderAlertList();
await screen.findByText('Weekly Sales Alert');
// Click delete on first owned alert
const deleteButtons = screen.getAllByTestId('delete-action');
fireEvent.click(deleteButtons[0]);
// Confirm in delete modal
const deleteInput = await screen.findByTestId('delete-modal-input');
fireEvent.change(deleteInput, { target: { value: 'DELETE' } });
const confirmButton = await screen.findByTestId('modal-confirm-button');
fireEvent.click(confirmButton);
// Row disappears after refetch
await waitFor(() => {
expect(screen.queryByText('Weekly Sales Alert')).not.toBeInTheDocument();
});
// Other alerts remain
expect(screen.getByText('Daily Error Alert')).toBeInTheDocument();
});
test('delete failure leaves row visible', async () => {
// Replace DELETE with 500
fetchMock.removeRoute('delete-alert');
fetchMock.delete(ENDPOINTS.SINGLE, 500, { name: 'delete-fail' });
renderAlertList();
await screen.findByText('Weekly Sales Alert');
const deleteButtons = screen.getAllByTestId('delete-action');
fireEvent.click(deleteButtons[0]);
const deleteInput = await screen.findByTestId('delete-modal-input');
fireEvent.change(deleteInput, { target: { value: 'DELETE' } });
const confirmButton = await screen.findByTestId('modal-confirm-button');
fireEvent.click(confirmButton);
await waitFor(() => {
expect(fetchMock.callHistory.calls('delete-fail')).toHaveLength(1);
});
// Row stays visible
expect(screen.getByText('Weekly Sales Alert')).toBeInTheDocument();
});
test('bulk select shows selected count and enables bulk actions after row selection', async () => {
renderAlertList();
await screen.findByText('Weekly Sales Alert');
// Bulk select controls not visible initially
expect(screen.queryByTestId('bulk-select-controls')).not.toBeInTheDocument();
// Toggle bulk select
const bulkSelectButton = screen.getByTestId('bulk-select-toggle');
fireEvent.click(bulkSelectButton);
// Controls appear with "0 Selected" text
await screen.findByTestId('bulk-select-controls');
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
'0 Selected',
);
// Deselect-all and action button not yet visible (nothing selected)
expect(
screen.queryByTestId('bulk-select-deselect-all'),
).not.toBeInTheDocument();
// Select all rows via checkboxes that appear in bulk mode
const checkboxes = screen.getAllByRole('checkbox');
// First checkbox is the header "select all" toggle
fireEvent.click(checkboxes[0]);
// Bulk action button and deselect-all appear after selection
await waitFor(() => {
expect(screen.getByTestId('bulk-select-action')).toBeInTheDocument();
});
expect(screen.getByTestId('bulk-select-deselect-all')).toBeInTheDocument();
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
'3 Selected',
);
});
test('read-only users do not see delete and bulk select controls', async () => {
fetchMock.removeRoutes().clearHistory();
setupMocks(['can_read']); // no can_write
const readOnlyUser = {
userId: 99,
firstName: 'Read',
lastName: 'Only',
};
const store = createStore();
render(
<Provider store={store}>
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<AlertList user={readOnlyUser} />
</QueryParamProvider>
</MemoryRouter>
</Provider>,
);
await screen.findByText('Weekly Sales Alert');
// No delete action buttons
expect(screen.queryAllByTestId('delete-action')).toHaveLength(0);
// No bulk select toggle
expect(screen.queryByTestId('bulk-select-toggle')).not.toBeInTheDocument();
// Switches are all disabled (user 99 doesn't own any alerts)
const switches = screen.getAllByRole('switch');
switches.forEach(sw => {
expect(sw).toBeDisabled();
});
});
test('empty API result shows empty state', async () => {
fetchMock.removeRoutes().clearHistory();
setupMocks(['can_read', 'can_write'], []);
renderAlertList();
expect(await screen.findByText(/no alerts yet/i)).toBeInTheDocument();
});
@@ -242,9 +242,13 @@ function AlertList({
}),
);
updateResource(update_id, { active: checked }, false, false)
.then()
.catch(() => setResourceCollection(original));
updateResource(update_id, { active: checked }, false, false).then(
response => {
if (!response) {
setResourceCollection(original);
}
},
);
}
},
[alerts, setResourceCollection, updateResource],
+212 -1207
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -18,12 +18,12 @@
"license": "Apache-2.0",
"dependencies": {
"cookie": "^1.1.1",
"hot-shots": "^14.2.0",
"ioredis": "^5.10.0",
"hot-shots": "^14.3.1",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.19.0"
"ws": "^8.20.0"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
@@ -31,21 +31,21 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.5",
"@types/node": "^25.5.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.57.0",
"eslint": "^10.1.0",
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^17.4.0",
"jest": "^29.7.0",
"prettier": "^3.8.1",
"ts-jest": "^29.4.6",
"ts-jest": "^29.4.9",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
"typescript-eslint": "^8.58.0"
},
"engines": {
"node": "^22.22.0",
+12 -7
View File
@@ -287,14 +287,19 @@ class BaseReportState:
except json.JSONDecodeError:
logger.debug("Anchor value is not a list, Fall back to single tab")
# Merge native_filters into existing urlParams instead of
# overwriting — dashboard_state may already have urlParams
# (e.g. standalone=true) that must be preserved.
state: DashboardPermalinkState = {**dashboard_state}
existing_params: list[tuple[str, str]] = state.get("urlParams") or []
merged_params: list[list[str]] = [
list(p) for p in existing_params if p[0] != "native_filters"
]
merged_params.append(["native_filters", native_filter_params or ""])
state["urlParams"] = merged_params # type: ignore[typeddict-item]
return [
self._get_tab_url(
{
"urlParams": [
["native_filters", native_filter_params] # type: ignore
],
**dashboard_state,
},
state,
user_friendly=user_friendly,
)
]
@@ -479,7 +484,7 @@ class BaseReportState:
raise ReportScheduleCsvTimeout() from ex
except Exception as ex:
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
logger.error(
logger.exception(
"CSV generation failed after %.2fs - execution_id: %s",
elapsed_seconds,
self._execution_id,
+3 -1
View File
@@ -344,7 +344,9 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
if clause and self.datasource:
try:
database = self.datasource.database
processor = get_template_processor(database=database)
processor = get_template_processor(
database=database, table=self.datasource
)
try:
clause = processor.process_template(clause, force=True)
except TemplateError as ex:
+1 -1
View File
@@ -1257,7 +1257,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
if parse_boolean_string(request.args.get("include_rendered_sql")):
try:
processor = get_template_processor(database=table.database)
processor = get_template_processor(database=table.database, table=table)
response["result"] = self.render_dataset_fields(
response["result"], processor
)
+1 -1
View File
@@ -37,7 +37,7 @@ class CrateEngineSpec(BaseEngineSpec):
"CrateDB is a distributed SQL database for machine data and IoT workloads."
),
"logo": "cratedb.svg",
"homepage_url": "https://crate.io/",
"homepage_url": "https://cratedb.com",
"categories": [DatabaseCategory.TIME_SERIES, DatabaseCategory.OPEN_SOURCE],
"pypi_packages": ["crate", "sqlalchemy-cratedb"],
"connection_string": "crate://{host}:{port}",
+14 -1
View File
@@ -54,6 +54,10 @@ Dashboard Management:
- generate_dashboard: Create a dashboard from chart IDs
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard
Database Connections:
- list_databases: List database connections with advanced filters (1-based pagination)
- get_database_info: Get detailed database connection info by ID (backend, capabilities)
Dataset Management:
- list_datasets: List datasets with advanced filters (1-based pagination)
- get_dataset_info: Get detailed dataset information by ID (includes columns/metrics)
@@ -114,12 +118,14 @@ To create a chart:
3. generate_explore_link(dataset_id, config) -> preview interactively
4. generate_chart(dataset_id, config, save_chart=True) -> save permanently
To find your own charts/dashboards:
To find your own charts/dashboards/databases:
1. get_instance_info -> get current_user.id
2. list_charts(filters=[{{"col": "created_by_fk",
"opr": "eq", "value": current_user.id}}])
3. Or: list_dashboards(filters=[{{"col": "created_by_fk",
"opr": "eq", "value": current_user.id}}])
4. Or: list_databases(filters=[{{"col": "created_by_fk",
"opr": "eq", "value": current_user.id}}])
To explore data with SQL:
1. list_datasets -> find a dataset and note its database_id
@@ -168,6 +174,8 @@ Query Examples:
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
- My dashboards:
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
- My databases:
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
To modify an existing chart (add filters, change metrics, change dimensions, etc.):
1. get_chart_info(chart_id) -> examine current configuration
@@ -422,6 +430,7 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
get_chart_data,
get_chart_info,
get_chart_preview,
get_chart_type_schema,
list_charts,
update_chart,
update_chart_preview,
@@ -432,6 +441,10 @@ from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
get_dashboard_info,
list_dashboards,
)
from superset.mcp_service.database.tool import ( # noqa: F401, E402
get_database_info,
list_databases,
)
from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
get_dataset_info,
list_datasets,
+51 -30
View File
@@ -434,6 +434,10 @@ def _setup_user_context() -> User | None:
"""
Set up user context for MCP tool execution.
Includes retry logic for stale database connections (e.g., SSL dropped
by proxy/load balancer after idle periods). On OperationalError, the
session is reset and the user lookup is retried once.
Returns:
User object with roles and groups loaded, or None if no Flask context
"""
@@ -446,38 +450,55 @@ def _setup_user_context() -> User | None:
if not has_request_context():
g.pop("user", None)
try:
user = get_user_from_request()
except RuntimeError as e:
# No Flask application context (e.g., prompts before middleware runs)
# This is expected for some FastMCP operations - return None gracefully
if "application context" in str(e):
logger.debug("No Flask app context available for user setup")
return None
raise
except ValueError as e:
# JWT user resolution failed (e.g. SAML subject not in DB).
# If middleware already set g.user (request context exists),
# use that instead of failing closed.
from flask import has_request_context
from sqlalchemy.exc import OperationalError
if has_request_context() and hasattr(g, "user") and g.user:
logger.warning(
"JWT user resolution failed (%s), using middleware-provided g.user=%s",
e,
g.user.username,
)
# Assign to local so relationship validation below runs
# (same as the normal path) to prevent detached instance errors.
user = g.user
else:
user = None # Ensure defined before loop in case of unexpected exit
for attempt in range(2):
try:
user = get_user_from_request()
# Validate user has necessary relationships loaded.
# Force access to ensure they're loaded if lazy.
# This is inside the retry loop because relationship loading
# also hits the DB and can fail on stale SSL connections.
user_roles = user.roles # noqa: F841
if hasattr(user, "groups"):
user_groups = user.groups # noqa: F841
break
except RuntimeError as e:
# No Flask application context (e.g., prompts before middleware runs)
if "application context" in str(e):
logger.debug("No Flask app context available for user setup")
return None
raise
except OperationalError as e:
if attempt == 0:
# Only retry on connection-level errors (SSL drops, server
# closed connection). Other OperationalErrors (e.g., lock
# timeouts) are unlikely to succeed on immediate retry but
# are bounded to one attempt so the cost is acceptable.
logger.warning(
"Stale DB connection during user setup (attempt 1), "
"resetting session and retrying: %s",
e,
)
_cleanup_session_on_error()
continue
logger.error("DB connection failed on retry during user setup: %s", e)
_cleanup_session_on_error()
raise
except ValueError as e:
# User resolution failed — fail closed. Do not fall back to
# g.user from middleware, as that could allow a request to
# proceed as a different user in multi-tenant deployments.
# Clear g.user so error/audit logging doesn't attribute
# the denied request to the middleware-provided identity.
logger.error("MCP user resolution failed, denying request: %s", e)
if has_request_context():
g.pop("user", None)
raise
# Validate user has necessary relationships loaded
# (Force access to ensure they're loaded if lazy)
user_roles = user.roles # noqa: F841
if hasattr(user, "groups"):
user_groups = user.groups # noqa: F841
g.user = user
return user
@@ -412,9 +412,12 @@ def map_table_config(config: TableChartConfig) -> Dict[str, Any]:
# Handle raw columns (no aggregation)
if raw_columns and not aggregated_metrics:
# Pure raw columns - show individual rows
# Include both "all_columns" (Superset table viz) and "columns"
# (QueryContextFactory validation) to avoid "Empty query?" errors
form_data.update(
{
"all_columns": raw_columns,
"columns": raw_columns,
"query_mode": "raw",
"include_time": False,
"order_desc": True,
@@ -39,6 +39,12 @@ logger = logging.getLogger(__name__)
def _build_query_columns(form_data: Dict[str, Any]) -> list[str]:
"""Build query columns list from form_data, including both x_axis and groupby."""
# Table charts in raw mode use all_columns or columns
all_columns = form_data.get("all_columns", [])
raw_columns_field = form_data.get("columns", [])
if form_data.get("query_mode") == "raw" and (all_columns or raw_columns_field):
return list(all_columns or raw_columns_field)
x_axis_config = form_data.get("x_axis")
groupby_columns: list[str] = form_data.get("groupby") or []
raw_columns: list[str] = form_data.get("columns") or []
+116 -6
View File
@@ -36,6 +36,7 @@ from pydantic import (
model_serializer,
model_validator,
PositiveInt,
TypeAdapter,
)
from typing_extensions import Self
@@ -74,6 +75,8 @@ class ChartLike(Protocol):
cache_timeout: int | None
form_data: Dict[str, Any] | None
query_context: Any | None
certified_by: str | None
certification_details: str | None
changed_by: Any | None # User object
changed_by_name: str | None
changed_on: str | datetime | None
@@ -113,6 +116,12 @@ class ChartInfo(BaseModel):
created_on_humanized: str | None = Field(
None, description="Humanized creation time"
)
certified_by: str | None = Field(
None, description="Name of the person or team who certified this chart"
)
certification_details: str | None = Field(
None, description="Certification details or reason"
)
uuid: str | None = Field(None, description="Chart UUID")
tags: List[TagInfo] = Field(default_factory=list, description="Chart tags")
owners: List[UserInfo] = Field(default_factory=list, description="Chart owners")
@@ -284,14 +293,25 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
if not chart:
return None
# Use the chart's native URL (explore URL) instead of screenshot URL
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json as utils_json
chart_id = getattr(chart, "id", None)
chart_url = None
if chart_id:
chart_url = f"{get_superset_base_url()}/explore/?slice_id={chart_id}"
# Parse form_data from the chart's params JSON string
chart_params = getattr(chart, "params", None)
chart_form_data = None
if chart_params and isinstance(chart_params, str):
try:
chart_form_data = utils_json.loads(chart_params)
except (TypeError, ValueError):
pass
elif isinstance(chart_params, dict):
chart_form_data = chart_params
return ChartInfo(
id=chart_id,
slice_name=getattr(chart, "slice_name", None),
@@ -300,7 +320,10 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
datasource_type=getattr(chart, "datasource_type", None),
url=chart_url,
description=getattr(chart, "description", None),
certified_by=getattr(chart, "certified_by", None),
certification_details=getattr(chart, "certification_details", None),
cache_timeout=getattr(chart, "cache_timeout", None),
form_data=chart_form_data,
changed_by=getattr(chart, "changed_by_name", None)
or (str(chart.changed_by) if getattr(chart, "changed_by", None) else None),
changed_by_name=getattr(chart, "changed_by_name", None),
@@ -1123,7 +1146,7 @@ class XYChartConfig(UnknownFieldCheckMixin):
return self
# Discriminated union entry point with custom error handling
# Discriminated union for runtime validation (not exposed in JSON Schema)
ChartConfig = Annotated[
XYChartConfig
| TableChartConfig
@@ -1142,6 +1165,66 @@ ChartConfig = Annotated[
),
]
# Module-level TypeAdapter avoids repeated schema compilation in
# parse_chart_config() — safe because ChartConfig is fully defined above.
_CHART_CONFIG_ADAPTER: TypeAdapter[ChartConfig] = TypeAdapter(ChartConfig)
# Compact description for JSON Schema — keeps tool inputSchema small while
# giving LLMs enough context to construct valid configs.
_CHART_CONFIG_DESCRIPTION = (
"Chart configuration object. MUST include 'chart_type' to select the "
"schema. Types: 'xy' (x, y, kind: line/bar/area/scatter), "
"'table' (columns), 'pie' (dimension, metric), "
"'pivot_table' (rows, metrics), 'mixed_timeseries' (x, y, y_secondary), "
"'handlebars' (columns, handlebars_template), "
"'big_number' (metric). "
"See chart://configs resource for full field reference and examples."
)
def parse_chart_config(
config: Dict[str, Any],
) -> (
XYChartConfig
| TableChartConfig
| PieChartConfig
| PivotTableChartConfig
| MixedTimeseriesChartConfig
| HandlebarsChartConfig
| BigNumberChartConfig
):
"""Parse a raw dict into the appropriate typed ChartConfig subclass.
Validates the dict against the discriminated union using chart_type.
Call this in tool function bodies to get a typed config object.
"""
try:
return _CHART_CONFIG_ADAPTER.validate_python(config)
except Exception as e:
raise ValueError(
f"{e}\n\n"
f"Hint: read the chart://configs resource for valid configuration "
f"examples and field reference."
) from e
def _coerce_config_to_dict(v: Any) -> Dict[str, Any]:
"""Accept ChartConfig objects, dicts, or JSON strings for the config field."""
if isinstance(v, str):
from superset.utils import json as json_utils
try:
v = json_utils.loads(v)
except (ValueError, TypeError) as exc:
raise ValueError(
f"config must be a JSON object string, got: {v!r}"
) from exc
if hasattr(v, "model_dump"):
return v.model_dump()
if isinstance(v, dict):
return v
raise TypeError(f"config must be a dict or JSON string, got {type(v).__name__}")
class ListChartsRequest(MetadataCacheControl):
"""Request schema for list_charts with clear, unambiguous types."""
@@ -1237,7 +1320,7 @@ class ListChartsRequest(MetadataCacheControl):
# The tool input models
class GenerateChartRequest(QueryCacheControl):
dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)")
config: ChartConfig = Field(..., description="Chart configuration")
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
chart_name: str | None = Field(
None, description="Auto-generates if omitted", max_length=255
)
@@ -1247,6 +1330,11 @@ class GenerateChartRequest(QueryCacheControl):
default_factory=lambda: ["url"],
)
@field_validator("config", mode="before")
@classmethod
def coerce_config(cls, v: Any) -> Dict[str, Any]:
return _coerce_config_to_dict(v)
@field_validator("chart_name")
@classmethod
def sanitize_chart_name(cls, v: str | None) -> str | None:
@@ -1279,12 +1367,22 @@ class GenerateChartRequest(QueryCacheControl):
class GenerateExploreLinkRequest(FormDataCacheControl):
dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)")
config: ChartConfig = Field(..., description="Chart configuration")
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
@field_validator("config", mode="before")
@classmethod
def coerce_config(cls, v: Any) -> Dict[str, Any]:
return _coerce_config_to_dict(v)
class UpdateChartRequest(QueryCacheControl):
identifier: int | str = Field(..., description="Chart ID or UUID")
config: ChartConfig
config: Dict[str, Any] | None = Field(
None,
description=(
f"{_CHART_CONFIG_DESCRIPTION} Optional; omit to only update chart_name."
),
)
chart_name: str | None = Field(
None, description="Auto-generates if omitted", max_length=255
)
@@ -1293,6 +1391,13 @@ class UpdateChartRequest(QueryCacheControl):
default_factory=lambda: ["url"],
)
@field_validator("config", mode="before")
@classmethod
def coerce_config(cls, v: Any) -> Dict[str, Any] | None:
if v is None:
return None
return _coerce_config_to_dict(v)
@field_validator("chart_name")
@classmethod
def sanitize_chart_name(cls, v: str | None) -> str | None:
@@ -1303,12 +1408,17 @@ class UpdateChartRequest(QueryCacheControl):
class UpdateChartPreviewRequest(FormDataCacheControl):
form_data_key: str = Field(..., description="Existing form_data_key to update")
dataset_id: int | str = Field(..., description="Dataset ID or UUID")
config: ChartConfig
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
generate_preview: bool = True
preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field(
default_factory=lambda: ["url"],
)
@field_validator("config", mode="before")
@classmethod
def coerce_config(cls, v: Any) -> Dict[str, Any]:
return _coerce_config_to_dict(v)
class GetChartDataRequest(QueryCacheControl):
"""Request for chart data with cache control.
@@ -19,6 +19,7 @@ from .generate_chart import generate_chart
from .get_chart_data import get_chart_data
from .get_chart_info import get_chart_info
from .get_chart_preview import get_chart_preview
from .get_chart_type_schema import get_chart_type_schema
from .list_charts import list_charts
from .update_chart import update_chart
from .update_chart_preview import update_chart_preview
@@ -31,4 +32,5 @@ __all__ = [
"update_chart_preview",
"get_chart_preview",
"get_chart_data",
"get_chart_type_schema",
]
@@ -43,6 +43,7 @@ from superset.mcp_service.chart.schemas import (
ChartError,
GenerateChartRequest,
GenerateChartResponse,
parse_chart_config,
PerformanceMetadata,
)
from superset.mcp_service.utils.url_utils import get_superset_base_url
@@ -209,13 +210,17 @@ async def generate_chart( # noqa: C901
"save_chart=%s, preview_formats=%s"
% (
request.dataset_id,
request.config.chart_type,
request.config.get("chart_type", "unknown"),
request.save_chart,
request.preview_formats,
)
)
await ctx.debug(
"Chart configuration details: config=%s" % (request.config.model_dump(),)
"Chart configuration details: chart_type=%s, keys=%s"
% (
request.config.get("chart_type", "unknown"),
sorted(request.config.keys()),
)
)
# Track runtime warnings to include in response
@@ -269,11 +274,12 @@ async def generate_chart( # noqa: C901
}
)
# Parse the raw config dict into a typed ChartConfig for downstream use
config = parse_chart_config(request.config)
# Map the simplified config to Superset's form_data format
# Pass dataset_id to enable column type checking for proper viz_type selection
form_data = map_config_to_form_data(
request.config, dataset_id=request.dataset_id
)
form_data = map_config_to_form_data(config, dataset_id=request.dataset_id)
chart = None
chart_id = None
@@ -367,7 +373,7 @@ async def generate_chart( # noqa: C901
dataset, "table_name", None
)
chart_name = request.chart_name or generate_chart_name(
request.config, dataset_name=dataset_name
config, dataset_name=dataset_name
)
await ctx.debug("Chart name: chart_name=%s" % (chart_name,))
@@ -607,8 +613,8 @@ async def generate_chart( # noqa: C901
response_warnings.extend(compile_result.warnings)
# Generate semantic analysis
capabilities = analyze_chart_capabilities(chart, request.config)
semantics = analyze_chart_semantics(chart, request.config)
capabilities = analyze_chart_capabilities(chart, config)
semantics = analyze_chart_semantics(chart, config)
# Create performance metadata
execution_time = int((time.time() - start_time) * 1000)
@@ -622,7 +628,7 @@ async def generate_chart( # noqa: C901
chart_name = (
chart.slice_name
if chart and hasattr(chart, "slice_name")
else generate_chart_name(request.config)
else generate_chart_name(config)
)
accessibility = AccessibilityMetadata(
color_blind_safe=True, # Would need actual analysis
@@ -843,9 +849,9 @@ async def generate_chart( # noqa: C901
# Extract chart_type from different sources for better error context
chart_type = "unknown"
try:
if hasattr(request, "config") and hasattr(request.config, "chart_type"):
chart_type = request.config.chart_type
except AttributeError as extract_error:
if hasattr(request, "config") and isinstance(request.config, dict):
chart_type = request.config.get("chart_type", "unknown")
except (AttributeError, TypeError) as extract_error:
# Ignore errors when extracting chart type for error context
logger.debug("Could not extract chart type: %s", extract_error)
@@ -301,7 +301,13 @@ async def get_chart_data( # noqa: C901
cached_groupby: list[str] = []
else:
cached_metrics = cached_form_data_dict.get("metrics", [])
cached_groupby = cached_form_data_dict.get("groupby", [])
raw_groupby = cached_form_data_dict.get("groupby", [])
# Guard against string groupby (e.g. heatmap_v2 migrated
# from legacy heatmap where all_columns_y was a string)
if isinstance(raw_groupby, str):
cached_groupby = [raw_groupby]
else:
cached_groupby = list(raw_groupby)
_apply_extra_form_data(cached_form_data_dict, request.extra_form_data)
@@ -443,7 +449,13 @@ async def get_chart_data( # noqa: C901
else:
# Standard charts use "metrics" (plural) and "groupby"
metrics = form_data.get("metrics", [])
groupby_columns = list(form_data.get("groupby") or [])
raw_groupby = form_data.get("groupby") or []
# Guard against string groupby (e.g. heatmap_v2 migrated
# from legacy heatmap where all_columns_y was a string)
if isinstance(raw_groupby, str):
groupby_columns = [raw_groupby]
else:
groupby_columns = list(raw_groupby)
# Some chart types use "columns" instead of "groupby"
if not groupby_columns:
form_columns = form_data.get("columns")
@@ -133,12 +133,28 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
groupby_columns = form_data.get("groupby", [])
metrics = form_data.get("metrics", [])
columns = groupby_columns.copy()
if x_axis_config and isinstance(x_axis_config, str):
columns.append(x_axis_config)
elif x_axis_config and isinstance(x_axis_config, dict):
if "column_name" in x_axis_config:
columns.append(x_axis_config["column_name"])
# Table charts in raw mode use all_columns or columns
all_columns = form_data.get("all_columns", [])
raw_columns = form_data.get("columns", [])
if form_data.get("query_mode") == "raw" and (all_columns or raw_columns):
columns = list(all_columns or raw_columns)
else:
columns = groupby_columns.copy()
if x_axis_config and isinstance(x_axis_config, str):
columns.append(x_axis_config)
elif x_axis_config and isinstance(x_axis_config, dict):
if "column_name" in x_axis_config:
columns.append(x_axis_config["column_name"])
if not columns and not metrics:
return ChartError(
error=(
"Cannot generate ASCII preview: chart has no columns or "
"metrics in its configuration. This chart type may not "
"support ASCII preview."
),
error_type="UnsupportedChart",
)
factory = QueryContextFactory()
query_context = factory.create(
@@ -0,0 +1,174 @@
# 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.
"""
MCP tool: get_chart_type_schema
"""
from __future__ import annotations
import logging
from typing import Any, Dict
from pydantic import TypeAdapter
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.mcp_service.chart.schemas import (
BigNumberChartConfig,
HandlebarsChartConfig,
MixedTimeseriesChartConfig,
PieChartConfig,
PivotTableChartConfig,
TableChartConfig,
XYChartConfig,
)
logger = logging.getLogger(__name__)
# Module-level TypeAdapters — one per chart type, compiled once.
_CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
"xy": TypeAdapter(XYChartConfig),
"table": TypeAdapter(TableChartConfig),
"pie": TypeAdapter(PieChartConfig),
"pivot_table": TypeAdapter(PivotTableChartConfig),
"mixed_timeseries": TypeAdapter(MixedTimeseriesChartConfig),
"handlebars": TypeAdapter(HandlebarsChartConfig),
"big_number": TypeAdapter(BigNumberChartConfig),
}
VALID_CHART_TYPES = sorted(_CHART_TYPE_ADAPTERS.keys())
# Per-type examples — lightweight inline examples for each chart type.
_CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
"xy": [
{
"chart_type": "xy",
"kind": "line",
"x": {"name": "order_date"},
"y": [{"name": "revenue", "aggregate": "SUM"}],
"time_grain": "P1D",
},
{
"chart_type": "xy",
"kind": "bar",
"x": {"name": "category"},
"y": [{"name": "sales", "aggregate": "SUM"}],
},
],
"table": [
{
"chart_type": "table",
"columns": [
{"name": "customer_name"},
{"name": "revenue", "aggregate": "SUM"},
],
},
],
"pie": [
{
"chart_type": "pie",
"dimension": {"name": "region"},
"metric": {"name": "revenue", "aggregate": "SUM"},
},
],
"pivot_table": [
{
"chart_type": "pivot_table",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"columns": [{"name": "quarter"}],
},
],
"mixed_timeseries": [
{
"chart_type": "mixed_timeseries",
"x": {"name": "order_date"},
"y": [{"name": "revenue", "aggregate": "SUM"}],
"y_secondary": [{"name": "orders", "aggregate": "COUNT"}],
"time_grain": "P1M",
},
],
"handlebars": [
{
"chart_type": "handlebars",
"query_mode": "raw",
"columns": [{"name": "customer_name"}, {"name": "email"}],
"handlebars_template": "{{#each data}}<p>{{customer_name}}</p>{{/each}}",
},
],
"big_number": [
{
"chart_type": "big_number",
"metric": {"name": "revenue", "aggregate": "SUM"},
},
],
}
def _get_chart_type_schema_impl(
chart_type: str,
include_examples: bool = True,
) -> Dict[str, Any]:
"""Pure logic for chart type schema lookup — no auth, no decorators."""
adapter = _CHART_TYPE_ADAPTERS.get(chart_type)
if adapter is None:
return {
"error": f"Unknown chart_type: {chart_type!r}",
"valid_chart_types": VALID_CHART_TYPES,
"hint": (
"Use one of the valid chart_type values listed above. "
"Call this tool again with a valid chart_type to see "
"its schema and examples."
),
}
schema = adapter.json_schema()
result: Dict[str, Any] = {
"chart_type": chart_type,
"schema": schema,
}
if include_examples:
result["examples"] = _CHART_EXAMPLES.get(chart_type, [])
return result
@tool(
tags=["discovery"],
annotations=ToolAnnotations(
title="Get chart type schema",
readOnlyHint=True,
destructiveHint=False,
),
)
def get_chart_type_schema(
chart_type: str,
include_examples: bool = True,
) -> Dict[str, Any]:
"""Get the full JSON Schema and examples for a specific chart type.
Use this tool to discover the exact fields, types, and constraints
for a chart configuration before calling generate_chart or update_chart.
Valid chart_type values: xy, table, pie, pivot_table,
mixed_timeseries, handlebars, big_number.
Returns the JSON Schema for the requested chart type, optionally
with working examples.
"""
return _get_chart_type_schema_impl(chart_type, include_examples)
@@ -46,6 +46,9 @@ DEFAULT_CHART_COLUMNS = [
"id",
"slice_name",
"viz_type",
"description",
"certified_by",
"certification_details",
"url",
"changed_on",
"changed_on_humanized",
+90 -42
View File
@@ -21,6 +21,7 @@ MCP tool: update_chart
import logging
import time
from typing import Any
from fastmcp import Context
from sqlalchemy.exc import SQLAlchemyError
@@ -37,6 +38,7 @@ from superset.mcp_service.chart.chart_utils import (
from superset.mcp_service.chart.schemas import (
AccessibilityMetadata,
GenerateChartResponse,
parse_chart_config,
PerformanceMetadata,
UpdateChartRequest,
)
@@ -46,6 +48,67 @@ from superset.utils import json
logger = logging.getLogger(__name__)
def _find_chart(identifier: int | str) -> Any | None:
"""Find a chart by numeric ID or UUID string."""
from superset.daos.chart import ChartDAO
if isinstance(identifier, int) or (
isinstance(identifier, str) and identifier.isdigit()
):
chart_id = int(identifier) if isinstance(identifier, str) else identifier
return ChartDAO.find_by_id(chart_id)
return ChartDAO.find_by_id(identifier, id_column="uuid")
def _build_update_payload(
request: UpdateChartRequest,
chart: Any,
) -> dict[str, Any] | GenerateChartResponse:
"""Build the update payload for a chart update.
Returns a dict payload on success, or a GenerateChartResponse error
when neither config nor chart_name is provided.
"""
if request.config is not None:
config = parse_chart_config(request.config)
dataset_id = chart.datasource_id if chart.datasource_id else None
new_form_data = map_config_to_form_data(config, dataset_id=dataset_id)
new_form_data.pop("_mcp_warnings", None)
chart_name = (
request.chart_name
if request.chart_name
else chart.slice_name or generate_chart_name(config)
)
return {
"slice_name": chart_name,
"viz_type": new_form_data["viz_type"],
"params": json.dumps(new_form_data),
}
# Name-only update: keep existing visualization, just rename
if not request.chart_name:
return GenerateChartResponse.model_validate(
{
"chart": None,
"error": {
"error_type": "ValidationError",
"message": ("Either 'config' or 'chart_name' must be provided."),
"details": (
"Either 'config' or 'chart_name' must be provided. "
"Use config for visualization changes, chart_name "
"for renaming."
),
},
"success": False,
"schema_version": "2.0",
"api_version": "v1",
}
)
return {"slice_name": request.chart_name}
@tool(
tags=["mutate"],
class_permission_name="Chart",
@@ -105,29 +168,22 @@ async def update_chart(
start_time = time.time()
try:
# Find the existing chart
from superset.daos.chart import ChartDAO
with event_logger.log_context(action="mcp.update_chart.chart_lookup"):
chart = None
if isinstance(request.identifier, int) or (
isinstance(request.identifier, str) and request.identifier.isdigit()
):
chart_id = (
int(request.identifier)
if isinstance(request.identifier, str)
else request.identifier
)
chart = ChartDAO.find_by_id(chart_id)
else:
# Try UUID lookup using DAO flexible method
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
chart = _find_chart(request.identifier)
if not chart:
return GenerateChartResponse.model_validate(
{
"chart": None,
"error": f"No chart found with identifier: {request.identifier}",
"error": {
"error_type": "NotFound",
"message": (
f"No chart found with identifier: {request.identifier}"
),
"details": (
f"No chart found with identifier: {request.identifier}"
),
},
"success": False,
"schema_version": "2.0",
"api_version": "v1",
@@ -157,35 +213,23 @@ async def update_chart(
}
)
# Map the new config to form_data format
# Get dataset_id from existing chart for column type checking
dataset_id = chart.datasource_id if chart.datasource_id else None
new_form_data = map_config_to_form_data(request.config, dataset_id=dataset_id)
new_form_data.pop("_mcp_warnings", None)
# Update chart using Superset's command
# Build update payload (config update or name-only rename)
from superset.commands.chart.update import UpdateChartCommand
payload_or_error = _build_update_payload(request, chart)
if isinstance(payload_or_error, GenerateChartResponse):
return payload_or_error
with event_logger.log_context(action="mcp.update_chart.db_write"):
# Generate new chart name if provided, otherwise keep existing
chart_name = (
request.chart_name
if request.chart_name
else chart.slice_name or generate_chart_name(request.config)
)
update_payload = {
"slice_name": chart_name,
"viz_type": new_form_data["viz_type"],
"params": json.dumps(new_form_data),
}
command = UpdateChartCommand(chart.id, update_payload)
command = UpdateChartCommand(chart.id, payload_or_error)
updated_chart = command.run()
# Parse config for analysis (may be None for name-only updates)
config = parse_chart_config(request.config) if request.config else None
# Generate semantic analysis
capabilities = analyze_chart_capabilities(updated_chart, request.config)
semantics = analyze_chart_semantics(updated_chart, request.config)
capabilities = analyze_chart_capabilities(updated_chart, config)
semantics = analyze_chart_semantics(updated_chart, config)
# Create performance metadata
execution_time = int((time.time() - start_time) * 1000)
@@ -199,7 +243,7 @@ async def update_chart(
chart_name = (
updated_chart.slice_name
if updated_chart and hasattr(updated_chart, "slice_name")
else generate_chart_name(request.config)
else (generate_chart_name(config) if config else "Updated chart")
)
accessibility = AccessibilityMetadata(
color_blind_safe=True, # Would need actual analysis
@@ -288,7 +332,11 @@ async def update_chart(
return GenerateChartResponse.model_validate(
{
"chart": None,
"error": f"Chart update failed: {str(e)}",
"error": {
"error_type": type(e).__name__,
"message": f"Chart update failed: {e}",
"details": str(e),
},
"performance": {
"query_duration_ms": execution_time,
"cache_status": "error",
@@ -36,13 +36,36 @@ from superset.mcp_service.chart.chart_utils import (
)
from superset.mcp_service.chart.schemas import (
AccessibilityMetadata,
parse_chart_config,
PerformanceMetadata,
UpdateChartPreviewRequest,
)
from superset.utils import json as utils_json
logger = logging.getLogger(__name__)
def _get_old_adhoc_filters(form_data_key: str) -> list[Dict[str, Any]] | None:
"""Retrieve adhoc_filters from the previously cached form_data."""
from superset.commands.exceptions import CommandException
from superset.commands.explore.form_data.get import GetFormDataCommand
from superset.commands.explore.form_data.parameters import CommandParameters
try:
cmd_params = CommandParameters(key=form_data_key)
cached_data = GetFormDataCommand(cmd_params).run()
if cached_data:
if isinstance(cached_data, str):
cached_data = utils_json.loads(cached_data)
if isinstance(cached_data, dict):
adhoc_filters = cached_data.get("adhoc_filters")
if adhoc_filters:
return adhoc_filters
except (KeyError, ValueError, TypeError, CommandException):
logger.debug("Could not retrieve old form_data for filter preservation")
return None
@tool(
tags=["mutate"],
class_permission_name="Chart",
@@ -73,14 +96,24 @@ def update_chart_preview(
start_time = time.time()
try:
# Parse the raw config dict into a typed ChartConfig
config = parse_chart_config(request.config)
with event_logger.log_context(action="mcp.update_chart_preview.form_data"):
# Map the new config to form_data format
# Pass dataset_id to enable column type checking
new_form_data = map_config_to_form_data(
request.config, dataset_id=request.dataset_id
config, dataset_id=request.dataset_id
)
new_form_data.pop("_mcp_warnings", None)
# Preserve adhoc filters from the previous cached form_data
# when the new config doesn't explicitly specify filters
if getattr(config, "filters", None) is None and request.form_data_key:
old_adhoc_filters = _get_old_adhoc_filters(request.form_data_key)
if old_adhoc_filters:
new_form_data["adhoc_filters"] = old_adhoc_filters
# Generate new explore link with updated form_data
explore_url = generate_explore_link(request.dataset_id, new_form_data)
@@ -91,8 +124,8 @@ def update_chart_preview(
with event_logger.log_context(action="mcp.update_chart_preview.metadata"):
# Generate semantic analysis
capabilities = analyze_chart_capabilities(None, request.config)
semantics = analyze_chart_semantics(None, request.config)
capabilities = analyze_chart_capabilities(None, config)
semantics = analyze_chart_semantics(None, config)
# Create performance metadata
execution_time = int((time.time() - start_time) * 1000)
@@ -103,7 +136,7 @@ def update_chart_preview(
)
# Create accessibility metadata
chart_name = generate_chart_name(request.config)
chart_name = generate_chart_name(config)
accessibility = AccessibilityMetadata(
color_blind_safe=True, # Would need actual analysis
alt_text=f"Updated chart preview showing {chart_name}",
@@ -26,6 +26,7 @@ from typing import Any, Dict, List, Tuple
from superset.mcp_service.chart.schemas import (
ChartConfig,
GenerateChartRequest,
parse_chart_config,
)
from superset.mcp_service.common.error_schemas import (
ChartGenerationError,
@@ -171,6 +172,10 @@ class ValidationPipeline:
if request is None:
return ValidationResult(is_valid=False, error=error)
# Parse the raw config dict into a typed ChartConfig for
# downstream validators that need typed access.
typed_config = parse_chart_config(request.config)
# Fetch dataset context once and reuse across validation layers
dataset_context = ValidationPipeline._get_dataset_context(
request.dataset_id
@@ -178,20 +183,20 @@ class ValidationPipeline:
# Layer 2: Dataset validation (reuses context)
is_valid, error = ValidationPipeline._validate_dataset(
request.config, request.dataset_id, dataset_context
typed_config, request.dataset_id, dataset_context
)
if not is_valid:
return ValidationResult(is_valid=False, request=request, error=error)
# Layer 3: Runtime validation - returns warnings as metadata, not errors
_is_valid, warnings_metadata = ValidationPipeline._validate_runtime(
request.config, request.dataset_id
typed_config, request.dataset_id
)
# Runtime validation always returns True now, warnings are informational
# Layer 4: Column name normalization (reuses context)
normalized_request = ValidationPipeline._normalize_column_names(
request, dataset_context
request, dataset_context, typed_config=typed_config
)
return ValidationResult(
@@ -284,6 +289,7 @@ class ValidationPipeline:
def _normalize_column_names(
request: GenerateChartRequest,
dataset_context: DatasetContext | None = None,
typed_config: ChartConfig | None = None,
) -> GenerateChartRequest:
"""
Normalize column names in the request to match canonical dataset names.
@@ -297,6 +303,8 @@ class ValidationPipeline:
request: The validated chart generation request
dataset_context: Pre-fetched dataset context to avoid duplicate
DB queries. If None, fetches from the database.
typed_config: Pre-parsed typed ChartConfig. If None, parses from
request.config dict.
Returns:
A new request with normalized column names
@@ -304,8 +312,9 @@ class ValidationPipeline:
try:
from .dataset_validator import DatasetValidator
config = typed_config or parse_chart_config(request.config)
normalized_config = DatasetValidator.normalize_column_names(
request.config,
config,
request.dataset_id,
dataset_context=dataset_context,
)
+141 -6
View File
@@ -29,6 +29,8 @@ import sqlalchemy as sa
from pydantic import BaseModel, Field
from sqlalchemy.inspection import inspect
from superset.mcp_service.constants import ModelType
class ColumnMetadata(BaseModel):
"""Metadata for a selectable column."""
@@ -52,7 +54,7 @@ class ModelSchemaInfo(BaseModel):
- Default values for each
"""
model_type: Literal["chart", "dataset", "dashboard"] = Field(
model_type: ModelType = Field(
..., description="The model type this schema describes"
)
select_columns: list[ColumnMetadata] = Field(
@@ -82,9 +84,7 @@ class ModelSchemaInfo(BaseModel):
class GetSchemaRequest(BaseModel):
"""Request schema for unified get_schema tool."""
model_type: Literal["chart", "dataset", "dashboard"] = Field(
..., description="Model type to get schema for"
)
model_type: ModelType = Field(..., description="Model type to get schema for")
class GetSchemaResponse(BaseModel):
@@ -180,6 +180,7 @@ def get_columns_from_model(
model_cls: Type[Any],
default_columns: list[str],
extra_columns: dict[str, ColumnMetadata] | None = None,
exclude_columns: set[str] | None = None,
) -> list[ColumnMetadata]:
"""
Dynamically extract column metadata from a SQLAlchemy model.
@@ -188,6 +189,7 @@ def get_columns_from_model(
model_cls: The SQLAlchemy model class to inspect
default_columns: List of column names that should be marked as defaults
extra_columns: Additional columns not on the model (e.g., computed fields)
exclude_columns: Column names to omit (e.g., sensitive fields)
Returns:
List of ColumnMetadata objects for all columns
@@ -197,6 +199,8 @@ def get_columns_from_model(
for col in mapper.columns:
col_name = col.key
if exclude_columns and col_name in exclude_columns:
continue
col_type = _get_sqlalchemy_type_name(col.type)
# Get description from column doc, comment, or fallback mapping
description = (
@@ -234,7 +238,17 @@ def get_columns_from_model(
# - Extra columns (computed/relationship fields not on the model)
# Chart configuration
CHART_DEFAULT_COLUMNS = ["id", "slice_name", "viz_type", "url", "changed_on_humanized"]
CHART_DEFAULT_COLUMNS = [
"id",
"slice_name",
"viz_type",
"description",
"certified_by",
"certification_details",
"url",
"changed_on",
"changed_on_humanized",
]
CHART_SORTABLE_COLUMNS = [
"id",
"slice_name",
@@ -302,6 +316,18 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
type="str",
is_default=False,
),
"certified_by": ColumnMetadata(
name="certified_by",
description="Name of the person who certified this chart",
type="str",
is_default=True,
),
"certification_details": ColumnMetadata(
name="certification_details",
description="Certification details or reason",
type="str",
is_default=True,
),
"tags": ColumnMetadata(
name="tags", description="Chart tags", type="list", is_default=False
),
@@ -311,7 +337,16 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
}
# Dataset configuration
DATASET_DEFAULT_COLUMNS = ["id", "table_name", "schema", "changed_on_humanized"]
DATASET_DEFAULT_COLUMNS = [
"id",
"table_name",
"schema",
"description",
"certified_by",
"certification_details",
"changed_on",
"changed_on_humanized",
]
DATASET_SORTABLE_COLUMNS = [
"id",
"table_name",
@@ -363,6 +398,18 @@ DATASET_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
type="str",
is_default=False,
),
"certified_by": ColumnMetadata(
name="certified_by",
description="Name of the person who certified this dataset",
type="str",
is_default=True,
),
"certification_details": ColumnMetadata(
name="certification_details",
description="Certification details or reason",
type="str",
is_default=True,
),
"metrics": ColumnMetadata(
name="metrics",
description="Dataset metrics definitions",
@@ -388,7 +435,11 @@ DASHBOARD_DEFAULT_COLUMNS = [
"id",
"dashboard_title",
"slug",
"description",
"certified_by",
"certification_details",
"url",
"changed_on",
"changed_on_humanized",
]
DASHBOARD_SORTABLE_COLUMNS = [
@@ -452,6 +503,68 @@ DASHBOARD_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
}
# Database configuration
DATABASE_DEFAULT_COLUMNS = [
"id",
"database_name",
"backend",
"expose_in_sqllab",
"changed_on",
"changed_on_humanized",
]
DATABASE_SORTABLE_COLUMNS = [
"id",
"database_name",
"changed_on",
"created_on",
]
DATABASE_SEARCH_COLUMNS = ["database_name"]
DATABASE_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
"backend": ColumnMetadata(
name="backend",
description="Database backend type (e.g., postgresql, mysql)",
type="str",
is_default=True,
),
"changed_by": ColumnMetadata(
name="changed_by",
description="Last modifier username",
type="str",
is_default=False,
),
"changed_by_name": ColumnMetadata(
name="changed_by_name",
description="Last modifier display name",
type="str",
is_default=False,
),
"changed_on_humanized": ColumnMetadata(
name="changed_on_humanized",
description="Humanized modification time",
type="str",
is_default=True,
),
"created_by": ColumnMetadata(
name="created_by",
description="Creator username",
type="str",
is_default=False,
),
"created_by_name": ColumnMetadata(
name="created_by_name",
description="Creator display name",
type="str",
is_default=False,
),
"created_on_humanized": ColumnMetadata(
name="created_on_humanized",
description="Humanized creation time",
type="str",
is_default=False,
),
}
def get_chart_columns() -> list[ColumnMetadata]:
"""Get column metadata for Chart model dynamically."""
from superset.models.slice import Slice
@@ -477,6 +590,27 @@ def get_dashboard_columns() -> list[ColumnMetadata]:
)
# Sensitive columns that should not be exposed via schema discovery
DATABASE_EXCLUDE_COLUMNS = {
"sqlalchemy_uri",
"password",
"encrypted_extra",
"server_cert",
}
def get_database_columns() -> list[ColumnMetadata]:
"""Get column metadata for Database model dynamically."""
from superset.models.core import Database
return get_columns_from_model(
Database,
DATABASE_DEFAULT_COLUMNS,
DATABASE_EXTRA_COLUMNS,
exclude_columns=DATABASE_EXCLUDE_COLUMNS,
)
def get_all_column_names(columns: list[ColumnMetadata]) -> list[str]:
"""Extract all column names from column metadata list."""
return [col.name for col in columns]
@@ -487,3 +621,4 @@ def get_all_column_names(columns: list[ColumnMetadata]) -> list[str]:
CHART_ALL_COLUMNS: list[str] = []
DATASET_ALL_COLUMNS: list[str] = []
DASHBOARD_ALL_COLUMNS: list[str] = []
DATABASE_ALL_COLUMNS: list[str] = []
+5
View File
@@ -16,6 +16,11 @@
# under the License.
"""Constants for the MCP service."""
from typing import Literal
# Supported model types for schema discovery and MCP tools
ModelType = Literal["chart", "dataset", "dashboard", "database"]
# Pagination defaults
DEFAULT_PAGE_SIZE = 10 # Default number of items per page
MAX_PAGE_SIZE = 100 # Maximum allowed page_size to prevent oversized responses
+17 -2
View File
@@ -93,6 +93,10 @@ from superset.mcp_service.system.schemas import (
TagInfo,
UserInfo,
)
from superset.mcp_service.utils.sanitization import (
_remove_dangerous_unicode,
_strip_html_tags,
)
class DashboardError(BaseModel):
@@ -445,9 +449,19 @@ class GenerateDashboardRequest(BaseModel):
)
description: str | None = Field(None, description="Description for the dashboard")
published: bool = Field(
default=True, description="Whether to publish the dashboard"
default=False, description="Whether to publish the dashboard"
)
@field_validator("dashboard_title")
@classmethod
def sanitize_dashboard_title(cls, v: str | None) -> str | None:
"""Strip HTML tags from dashboard title to prevent XSS."""
if v is None:
return None
v = _strip_html_tags(v.strip())
v = _remove_dangerous_unicode(v)
return v
class GenerateDashboardResponse(BaseModel):
"""Response schema for dashboard generation."""
@@ -566,8 +580,9 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
else None,
chart_count=len(getattr(dashboard, "slices", [])),
owners=[
UserInfo.model_validate(owner, from_attributes=True)
info
for owner in getattr(dashboard, "owners", [])
if (info := serialize_user_object(owner)) is not None
]
if getattr(dashboard, "owners", None)
else [],
@@ -48,6 +48,9 @@ DEFAULT_DASHBOARD_COLUMNS = [
"id",
"dashboard_title",
"slug",
"description",
"certified_by",
"certification_details",
"url",
"changed_on",
"changed_on_humanized",
+16
View File
@@ -0,0 +1,16 @@
# 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.
+364
View File
@@ -0,0 +1,364 @@
# 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.
"""
Pydantic schemas for database-related responses
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal
import humanize
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.common.cache_schemas import MetadataCacheControl
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
from superset.utils import json
class DatabaseFilter(ColumnOperator):
"""
Filter object for database listing.
col: The column to filter on. Must be one of the allowed filter fields.
opr: The operator to use. Must be one of the supported operators.
value: The value to filter by (type depends on col and opr).
"""
col: Literal[
"database_name",
"expose_in_sqllab",
"allow_file_upload",
"created_by_fk",
"changed_by_fk",
] = Field(
...,
description="Column to filter on. Use get_schema(model_type='database') for "
"available filter columns. Use created_by_fk with the user "
"ID from get_instance_info's current_user to find "
"databases created by a specific user.",
)
opr: ColumnOperatorEnum = Field(
...,
description="Operator to use. Use get_schema(model_type='database') for "
"available operators.",
)
value: str | int | float | bool | List[str | int | float | bool] = Field(
..., description="Value to filter by (type depends on col and opr)"
)
class DatabaseInfo(BaseModel):
id: int | None = Field(None, description="Database ID")
uuid: str | None = Field(None, description="Database UUID")
database_name: str | None = Field(None, description="Database connection name")
backend: str | None = Field(None, description="Database backend (e.g., postgresql)")
expose_in_sqllab: bool | None = Field(
None, description="Whether exposed in SQL Lab"
)
allow_ctas: bool | None = Field(
None, description="Whether CREATE TABLE AS is allowed"
)
allow_cvas: bool | None = Field(
None, description="Whether CREATE VIEW AS is allowed"
)
allow_dml: bool | None = Field(
None, description="Whether DML statements are allowed"
)
allow_file_upload: bool | None = Field(
None, description="Whether file upload is allowed"
)
allow_run_async: bool | None = Field(
None, description="Whether async query execution is allowed"
)
cache_timeout: int | None = Field(
None, description="Cache timeout override in seconds"
)
configuration_method: str | None = Field(
None, description="Configuration method (sqlalchemy_form or dynamic_form)"
)
force_ctas_schema: str | None = Field(
None, description="Schema to force for CTAS queries"
)
impersonate_user: bool | None = Field(
None, description="Whether to impersonate the logged-in user"
)
is_managed_externally: bool | None = Field(
None, description="Whether managed by an external system"
)
external_url: str | None = Field(
None, description="URL of the external management system"
)
extra: Dict[str, Any | None] | None = Field(None, description="Extra configuration")
changed_by: str | None = Field(None, description="Last modifier (username)")
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
)
changed_on_humanized: str | None = Field(
None, description="Humanized modification time"
)
created_by: str | None = Field(None, description="Database creator (username)")
created_on: str | datetime | None = Field(None, description="Creation timestamp")
created_on_humanized: str | None = Field(
None, description="Humanized creation time"
)
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
@model_serializer(mode="wrap", when_used="json")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
"""Filter fields based on serialization context.
If context contains 'select_columns', only include those fields.
Otherwise, include all fields (default behavior).
"""
data = serializer(self)
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
requested_fields = set(select_columns)
return {k: v for k, v in data.items() if k in requested_fields}
return data
class DatabaseList(BaseModel):
databases: List[DatabaseInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: List[str] = Field(
default_factory=list,
description="Requested columns for the response",
)
columns_loaded: List[str] = Field(
default_factory=list,
description="Columns that were actually loaded for each database",
)
columns_available: List[str] = Field(
default_factory=list,
description="All columns available for selection via select_columns parameter",
)
sortable_columns: List[str] = Field(
default_factory=list,
description="Columns that can be used with order_column parameter",
)
filters_applied: List[DatabaseFilter] = Field(
default_factory=list,
description="List of advanced filter dicts applied to the query.",
)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListDatabasesRequest(MetadataCacheControl):
"""Request schema for list_databases with clear, unambiguous types."""
filters: Annotated[
List[DatabaseFilter],
Field(
default_factory=list,
description="List of filter objects (column, operator, value). Each "
"filter is an object with 'col', 'opr', and 'value' "
"properties. Cannot be used together with 'search'.",
),
]
select_columns: Annotated[
List[str],
Field(
default_factory=list,
description="List of columns to select. Defaults to common columns if not "
"specified.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search string to match against database fields. Cannot "
"be used together with 'filters'.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(
default="desc", description="Direction to order results ('asc' or 'desc')"
),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number for pagination (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> List[DatabaseFilter]:
"""Accept both JSON string and list of objects."""
return parse_json_or_model_list(v, DatabaseFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> List[str]:
"""Accept JSON array, list, or comma-separated string."""
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListDatabasesRequest":
"""Prevent using both search and filters simultaneously to avoid query
conflicts."""
if self.search and self.filters:
raise ValueError(
"Cannot use both 'search' and 'filters' parameters simultaneously. "
"Use either 'search' for text-based searching across multiple fields, "
"or 'filters' for precise column-based filtering, but not both."
)
return self
class DatabaseError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
@classmethod
def create(cls, error: str, error_type: str) -> "DatabaseError":
"""Create a standardized DatabaseError with timestamp."""
from datetime import datetime, timezone
return cls(
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
)
class GetDatabaseInfoRequest(MetadataCacheControl):
"""Request schema for get_database_info with support for ID or UUID."""
identifier: Annotated[
int | str,
Field(description="Database identifier - can be numeric ID or UUID string"),
]
def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None:
"""Parse a field that may be stored as a JSON string into a dict."""
value = getattr(obj, field_name, None)
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except (ValueError, TypeError):
pass
return None
return value
def _humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
return humanize.naturaltime(now - dt)
def _get_backend(database: Any) -> str | None:
"""Safely get backend from a Database object or row proxy.
backend is a @property that decrypts sqlalchemy_uri, which fails on
row proxies returned by column-only DAO list queries. Fall back to None
when the property raises.
"""
try:
return database.backend
except (AttributeError, TypeError):
return None
def serialize_database_object(database: Any) -> DatabaseInfo | None:
if not database:
return None
return DatabaseInfo(
id=getattr(database, "id", None),
uuid=str(getattr(database, "uuid", ""))
if getattr(database, "uuid", None)
else None,
database_name=getattr(database, "database_name", None),
backend=_get_backend(database),
expose_in_sqllab=getattr(database, "expose_in_sqllab", None),
allow_ctas=getattr(database, "allow_ctas", None),
allow_cvas=getattr(database, "allow_cvas", None),
allow_dml=getattr(database, "allow_dml", None),
allow_file_upload=getattr(database, "allow_file_upload", None),
allow_run_async=getattr(database, "allow_run_async", None),
cache_timeout=getattr(database, "cache_timeout", None),
configuration_method=getattr(database, "configuration_method", None),
force_ctas_schema=getattr(database, "force_ctas_schema", None),
impersonate_user=getattr(database, "impersonate_user", None),
is_managed_externally=getattr(database, "is_managed_externally", None),
external_url=getattr(database, "external_url", None),
extra=_parse_json_field(database, "extra"),
changed_by=getattr(database, "changed_by_name", None)
or (
str(database.changed_by) if getattr(database, "changed_by", None) else None
),
changed_on=getattr(database, "changed_on", None),
changed_on_humanized=_humanize_timestamp(getattr(database, "changed_on", None)),
created_by=getattr(database, "created_by_name", None)
or (
str(database.created_by) if getattr(database, "created_by", None) else None
),
created_on=getattr(database, "created_on", None),
created_on_humanized=_humanize_timestamp(getattr(database, "created_on", None)),
)
@@ -0,0 +1,24 @@
# 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.
from .get_database_info import get_database_info
from .list_databases import list_databases
__all__ = [
"list_databases",
"get_database_info",
]
@@ -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.
"""
Get database info FastMCP tool
This module contains the FastMCP tool for getting detailed information
about a specific database connection.
"""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.database.schemas import (
DatabaseError,
DatabaseInfo,
GetDatabaseInfoRequest,
serialize_database_object,
)
from superset.mcp_service.mcp_core import ModelGetInfoCore
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Database",
annotations=ToolAnnotations(
title="Get database info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_database_info(
request: GetDatabaseInfoRequest, ctx: Context
) -> DatabaseInfo | DatabaseError:
"""Get database connection metadata by ID or UUID.
Returns database configuration including backend type and capabilities
(allow_ctas, allow_dml, expose_in_sqllab, etc.).
IMPORTANT FOR LLM CLIENTS:
- Use numeric ID (e.g., 123) or UUID string (e.g., "a1b2c3d4-...")
- To find a database ID, use the list_databases tool first
Example usage:
```json
{
"identifier": 1
}
```
Or with UUID:
```json
{
"identifier": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}
```
"""
await ctx.info(
"Retrieving database information: identifier=%s" % (request.identifier,)
)
await ctx.debug(
"Metadata cache settings: use_cache=%s refresh_metadata=%s force_refresh=%s"
% (
request.use_cache,
request.refresh_metadata,
request.force_refresh,
)
)
try:
from superset.daos.database import DatabaseDAO
with event_logger.log_context(action="mcp.get_database_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=DatabaseDAO,
output_schema=DatabaseInfo,
error_schema=DatabaseError,
serializer=serialize_database_object,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.identifier)
if isinstance(result, DatabaseInfo):
await ctx.info(
"Database information retrieved successfully: "
"database_id=%s, database_name=%s, backend=%s"
% (
result.id,
result.database_name,
result.backend,
)
)
else:
await ctx.warning(
"Database retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"Database information retrieval failed: identifier=%s, error=%s, "
"error_type=%s"
% (
request.identifier,
str(e),
type(e).__name__,
)
)
return DatabaseError(
error=f"Failed to get database info: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)
@@ -0,0 +1,166 @@
# 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.
"""
List databases FastMCP tool (Advanced with metadata cache control)
This module contains the FastMCP tool for listing databases using
advanced filtering with clear, unambiguous request schema and metadata cache control.
"""
import logging
from typing import TYPE_CHECKING
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
from superset.models.core import Database
from superset.extensions import event_logger
from superset.mcp_service.database.schemas import (
DatabaseFilter,
DatabaseInfo,
DatabaseList,
ListDatabasesRequest,
serialize_database_object,
)
from superset.mcp_service.mcp_core import ModelListCore
logger = logging.getLogger(__name__)
@tool(
tags=["core"],
class_permission_name="Database",
annotations=ToolAnnotations(
title="List databases",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_databases(request: ListDatabasesRequest, ctx: Context) -> DatabaseList:
"""List database connections with filtering and search.
Returns database metadata including name, backend type, and permissions.
Sortable columns for order_column: id, database_name, changed_on,
created_on
"""
await ctx.info(
"Listing databases: page=%s, page_size=%s, search=%s"
% (
request.page,
request.page_size,
request.search,
)
)
await ctx.debug(
"Database listing parameters: filters=%s, order_column=%s, "
"order_direction=%s, select_columns=%s"
% (
request.filters,
request.order_column,
request.order_direction,
request.select_columns,
)
)
await ctx.debug(
"Metadata cache settings: use_cache=%s, refresh_metadata=%s, force_refresh=%s"
% (
request.use_cache,
request.refresh_metadata,
request.force_refresh,
)
)
try:
from superset.daos.database import DatabaseDAO
from superset.mcp_service.common.schema_discovery import (
DATABASE_DEFAULT_COLUMNS,
DATABASE_SORTABLE_COLUMNS,
get_all_column_names,
get_database_columns,
)
# Get all column names dynamically from the model
all_columns = get_all_column_names(get_database_columns())
def _serialize_database(
obj: "Database | None", cols: list[str] | None
) -> DatabaseInfo | None:
"""Serialize database (filtering via model_serializer)."""
return serialize_database_object(obj)
# Create tool with standard serialization
list_tool = ModelListCore(
dao_class=DatabaseDAO,
output_schema=DatabaseInfo,
item_serializer=_serialize_database,
filter_type=DatabaseFilter,
default_columns=DATABASE_DEFAULT_COLUMNS,
search_columns=["database_name"],
list_field_name="databases",
output_list_schema=DatabaseList,
all_columns=all_columns,
sortable_columns=DATABASE_SORTABLE_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_databases.query"):
result = list_tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"Databases listed successfully: count=%s, total_count=%s, total_pages=%s"
% (
len(result.databases) if hasattr(result, "databases") else 0,
getattr(result, "total_count", None),
getattr(result, "total_pages", None),
)
)
# Apply field filtering via serialization context
columns_to_filter = result.columns_requested
await ctx.debug(
"Applying field filtering via serialization context: columns=%s"
% (columns_to_filter,)
)
with event_logger.log_context(action="mcp.list_databases.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(
"Database listing failed: page=%s, page_size=%s, error=%s, error_type=%s"
% (
request.page,
request.page_size,
str(e),
type(e).__name__,
)
)
raise
+17 -1
View File
@@ -102,6 +102,12 @@ class DatasetInfo(BaseModel):
schema_name: str | None = Field(None, description="Schema name", alias="schema")
database_name: str | None = Field(None, description="Database name")
description: str | None = Field(None, description="Dataset description")
certified_by: str | None = Field(
None, description="Name of the person or team who certified this dataset"
)
certification_details: str | None = Field(
None, description="Certification details or reason"
)
changed_by: str | None = Field(None, description="Last modifier (username)")
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
@@ -324,6 +330,9 @@ def _humanize_timestamp(dt: datetime | None) -> str | None:
def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
if not dataset:
return None
from superset.mcp_service.utils.url_utils import get_superset_base_url
params = getattr(dataset, "params", None)
if isinstance(params, str):
try:
@@ -360,6 +369,8 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
if getattr(dataset, "database", None)
else None,
description=getattr(dataset, "description", None),
certified_by=getattr(dataset, "certified_by", None),
certification_details=getattr(dataset, "certification_details", None),
changed_by=getattr(dataset, "changed_by_name", None)
or (str(dataset.changed_by) if getattr(dataset, "changed_by", None) else None),
changed_on=getattr(dataset, "changed_on", None),
@@ -387,7 +398,12 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
if getattr(dataset, "uuid", None)
else None,
schema_perm=getattr(dataset, "schema_perm", None),
url=getattr(dataset, "url", None),
url=(
f"{get_superset_base_url()}/tablemodelview/edit/"
f"{getattr(dataset, 'id', None)}"
if getattr(dataset, "id", None)
else None
),
sql=getattr(dataset, "sql", None),
main_dttm_col=getattr(dataset, "main_dttm_col", None),
offset=getattr(dataset, "offset", None),
@@ -48,6 +48,9 @@ DEFAULT_DATASET_COLUMNS = [
"id",
"table_name",
"schema",
"description",
"certified_by",
"certification_details",
"changed_on",
"changed_on_humanized",
]
@@ -35,6 +35,7 @@ from superset.mcp_service.chart.chart_utils import (
)
from superset.mcp_service.chart.schemas import (
GenerateExploreLinkRequest,
parse_chart_config,
)
@@ -89,7 +90,7 @@ async def generate_explore_link(
"""
await ctx.info(
"Generating explore link for dataset_id=%s, chart_type=%s"
% (request.dataset_id, request.config.chart_type)
% (request.dataset_id, request.config.get("chart_type", "unknown"))
)
await ctx.debug(
"Configuration details: use_cache=%s, force_refresh=%s, cache_form_data=%s"
@@ -97,6 +98,9 @@ async def generate_explore_link(
)
try:
# Parse the raw config dict into a typed ChartConfig
config = parse_chart_config(request.config)
await ctx.report_progress(1, 4, "Validating dataset exists")
with event_logger.log_context(action="mcp.generate_explore_link.dataset_check"):
from superset.daos.dataset import DatasetDAO
@@ -138,10 +142,10 @@ async def generate_explore_link(
)
normalized_config = DatasetValidator.normalize_column_names(
request.config, request.dataset_id
config, request.dataset_id
)
except (ImportError, AttributeError, KeyError, ValueError, TypeError):
normalized_config = request.config
normalized_config = config
# Map config to form_data using shared utilities
form_data = map_config_to_form_data(
@@ -197,7 +201,12 @@ async def generate_explore_link(
except Exception as e:
await ctx.error(
"Explore link generation failed for dataset_id=%s, chart_type=%s: %s: %s"
% (request.dataset_id, request.config.chart_type, type(e).__name__, str(e))
% (
request.dataset_id,
request.config.get("chart_type", "unknown"),
type(e).__name__,
str(e),
)
)
return {
"url": "",
+40 -14
View File
@@ -25,6 +25,7 @@ from typing import Any, Callable, Dict, Generic, List, Literal, Type, TypeVar
from pydantic import BaseModel
from superset.daos.base import BaseDAO
from superset.mcp_service.constants import ModelType
from superset.mcp_service.utils import _is_uuid
# Type variables for generic model tools
@@ -160,11 +161,23 @@ class ModelListCore(BaseCore, Generic[L]):
select_columns = parse_json_or_list(
select_columns, param_name="select_columns"
)
columns_to_load = select_columns
columns_to_load = list(select_columns)
columns_requested = select_columns
else:
columns_to_load = self.default_columns
columns_to_load = list(self.default_columns)
columns_requested = self.default_columns
# Ensure computed columns have their dependencies loaded.
# Humanized timestamps are derived from their raw counterparts —
# if the raw column isn't loaded, the serializer produces null.
computed_deps: dict[str, str] = {
"changed_on_humanized": "changed_on",
"created_on_humanized": "created_on",
}
for computed, dependency in computed_deps.items():
if computed in columns_to_load and dependency not in columns_to_load:
columns_to_load.append(dependency)
# Query the DAO
items: List[Any]
items, total_count = self.dao_class.list(
@@ -521,7 +534,7 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
def __init__(
self,
model_type: Literal["chart", "dataset", "dashboard"],
model_type: ModelType,
dao_class: Type[BaseDAO[Any]],
output_schema: Type[S],
select_columns: List[Any],
@@ -530,13 +543,14 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
search_columns: List[str],
default_sort: str = "changed_on",
default_sort_direction: Literal["asc", "desc"] = "desc",
exclude_filter_columns: set[str] | None = None,
logger: logging.Logger | None = None,
) -> None:
"""
Initialize the schema discovery core.
Args:
model_type: The type of model (chart, dataset, dashboard)
model_type: The type of model (chart, dataset, dashboard, database)
dao_class: The DAO class to query for filter columns
output_schema: Pydantic schema for the response (e.g., ModelSchemaInfo)
select_columns: Column metadata (List[ColumnMetadata] or similar)
@@ -545,6 +559,8 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
search_columns: Column names used for text search
default_sort: Default sort column
default_sort_direction: Default sort direction
exclude_filter_columns: Column names to omit from filter discovery
(e.g., sensitive fields like passwords or connection URIs)
logger: Optional logger instance
"""
super().__init__(logger)
@@ -557,6 +573,7 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
self.search_columns = search_columns
self.default_sort = default_sort
self.default_sort_direction = default_sort_direction
self.exclude_filter_columns = exclude_filter_columns or set()
def _get_filter_columns(self) -> Dict[str, List[str]]:
"""Get filterable columns and operators from the DAO."""
@@ -567,16 +584,25 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
return {}
# Convert to dict safely - handle both dict and dict-like objects
if isinstance(filterable, dict):
return dict(filterable)
# Try to convert mapping-like objects
try:
return dict(filterable)
except (TypeError, ValueError):
self._log_warning(
f"Unexpected filter columns type for {self.model_type}: "
f"{type(filterable)}"
)
return {}
result = dict(filterable)
else:
# Try to convert mapping-like objects
try:
result = dict(filterable)
except (TypeError, ValueError):
self._log_warning(
f"Unexpected filter columns type for {self.model_type}: "
f"{type(filterable)}"
)
return {}
# Remove excluded columns (e.g., sensitive fields)
if self.exclude_filter_columns:
result = {
k: v
for k, v in result.items()
if k not in self.exclude_filter_columns
}
return result
except Exception as e:
self._log_warning(
f"Failed to get filter columns for {self.model_type}: {e}"
+76 -4
View File
@@ -936,6 +936,72 @@ class ResponseSizeGuardMiddleware(Middleware):
excluded_tools = [excluded_tools]
self.excluded_tools = set(excluded_tools or [])
def _try_truncate_info_response(
self,
tool_name: str,
response: Any,
estimated_tokens: int,
) -> Any | None:
"""Attempt to dynamically truncate an info tool response to fit the limit.
Returns the truncated response if successful, None otherwise.
"""
from superset.mcp_service.utils.token_utils import (
estimate_response_tokens,
truncate_oversized_response,
)
try:
truncated, was_truncated, notes = truncate_oversized_response(
response, self.token_limit
)
except (MemoryError, RecursionError) as trunc_error:
logger.warning(
"Truncation failed for %s due to %s: %s",
tool_name,
type(trunc_error).__name__,
trunc_error,
)
return None
if not was_truncated:
return None
truncated_tokens = estimate_response_tokens(truncated)
if truncated_tokens > self.token_limit:
return None
logger.warning(
"Response for %s truncated from ~%d to ~%d tokens (limit: %d). Fields: %s",
tool_name,
estimated_tokens,
truncated_tokens,
self.token_limit,
"; ".join(notes),
)
try:
user_id = get_user_id()
event_logger.log(
user_id=user_id,
action="mcp_response_truncated",
curated_payload={
"tool": tool_name,
"original_tokens": estimated_tokens,
"truncated_tokens": truncated_tokens,
"token_limit": self.token_limit,
"truncation_notes": notes,
},
)
except Exception as log_error: # noqa: BLE001
logger.warning("Failed to log truncation event: %s", log_error)
if isinstance(truncated, dict):
truncated["_response_truncated"] = True
truncated["_truncation_notes"] = notes
return truncated
async def on_call_tool(
self,
context: MiddlewareContext,
@@ -984,9 +1050,18 @@ class ResponseSizeGuardMiddleware(Middleware):
# Block if over limit
if estimated_tokens > self.token_limit:
# Extract params for smart suggestions
params = getattr(context.message, "params", {}) or {}
# For info tools, try dynamic truncation before blocking
from superset.mcp_service.utils.token_utils import INFO_TOOLS
if tool_name in INFO_TOOLS:
truncated = self._try_truncate_info_response(
tool_name, response, estimated_tokens
)
if truncated is not None:
return truncated
# Log the blocked response
logger.error(
"Response blocked for %s: ~%d tokens exceeds limit of %d",
@@ -1011,9 +1086,6 @@ class ResponseSizeGuardMiddleware(Middleware):
except Exception as log_error: # noqa: BLE001
logger.warning("Failed to log size exceeded event: %s", log_error)
# Generate helpful error message with suggestions
# Avoid passing the full `response` (which may be huge) into the formatter
# to prevent large-memory operations during error formatting.
error_message = format_size_limit_error(
tool_name=tool_name,
params=params,
@@ -27,7 +27,7 @@ from urllib.parse import urlencode
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.extensions import db, event_logger
from superset.mcp_service.sql_lab.schemas import (
OpenSqlLabRequest,
SqlLabResponse,
@@ -118,6 +118,15 @@ def open_sql_lab_with_context(
)
except Exception as e:
try:
db.session.rollback() # pylint: disable=consider-using-transaction
except Exception: # noqa: BLE001
# Broad catch: the DB connection itself may be broken (e.g.,
# SSL drop), so even rollback can fail with non-SQLAlchemy
# errors. This is a cleanup path — swallow and log.
logger.warning(
"Database rollback failed during error handling", exc_info=True
)
logger.error("Error generating SQL Lab URL: %s", e)
return SqlLabResponse(
url="",
@@ -23,9 +23,10 @@ InstanceInfoCore for flexible, extensible metrics calculation.
import logging
from fastmcp import Context
from sqlalchemy.exc import OperationalError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.extensions import db, event_logger
from superset.mcp_service.mcp_core import InstanceInfoCore
from superset.mcp_service.system.schemas import (
GetSupersetInstanceInfoRequest,
@@ -92,38 +93,75 @@ def get_instance_info(
Returns counts, activity metrics, and database types.
"""
try:
# Import DAOs at runtime to avoid circular imports
from flask import g
return _run_instance_info()
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.daos.database import DatabaseDAO
from superset.daos.dataset import DatasetDAO
from superset.daos.tag import TagDAO
from superset.daos.user import UserDAO
except OperationalError as e:
logger.warning(
"Database connection error in get_instance_info, "
"resetting session and retrying: %s",
e,
)
try:
db.session.rollback() # pylint: disable=consider-using-transaction
except Exception: # noqa: BLE001
# Broad catch: the DB connection itself may be broken (e.g.,
# SSL drop), so even rollback can fail with non-SQLAlchemy
# errors. This is a cleanup path — swallow and log.
logger.warning(
"Rollback failed during get_instance_info connection reset",
exc_info=True,
)
try:
db.session.remove() # pylint: disable=consider-using-transaction
except Exception: # noqa: BLE001
# Same as above — cleanup must not prevent the retry.
logger.warning(
"Session remove failed during get_instance_info connection reset",
exc_info=True,
)
# Configure DAO classes at runtime
_instance_info_core.dao_classes = {
"dashboards": DashboardDAO,
"charts": ChartDAO,
"datasets": DatasetDAO,
"databases": DatabaseDAO,
"users": UserDAO,
"tags": TagDAO,
}
# Run the configurable core
with event_logger.log_context(action="mcp.get_instance_info.metrics"):
result = _instance_info_core.run_tool()
# Attach the authenticated user's identity to the response
user = getattr(g, "user", None)
if user is not None:
result.current_user = serialize_user_object(user)
return result
try:
result = _run_instance_info()
logger.info("get_instance_info retry succeeded after connection reset")
return result
except OperationalError as retry_error:
logger.error(
"get_instance_info retry failed after connection reset: %s",
retry_error,
exc_info=True,
)
raise
except Exception as e:
error_msg = f"Unexpected error in instance info: {str(e)}"
logger.error(error_msg, exc_info=True)
raise
def _run_instance_info() -> InstanceInfo:
"""Execute the instance info core logic."""
from flask import g
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.daos.database import DatabaseDAO
from superset.daos.dataset import DatasetDAO
from superset.daos.tag import TagDAO
from superset.daos.user import UserDAO
_instance_info_core.dao_classes = {
"dashboards": DashboardDAO,
"charts": ChartDAO,
"datasets": DatasetDAO,
"databases": DatabaseDAO,
"users": UserDAO,
"tags": TagDAO,
}
with event_logger.log_context(action="mcp.get_instance_info.metrics"):
result = _instance_info_core.run_tool()
if (user := getattr(g, "user", None)) is not None:
result.current_user = serialize_user_object(user)
return result
+30 -3
View File
@@ -24,7 +24,7 @@ Column metadata is extracted dynamically from SQLAlchemy models.
"""
import logging
from typing import Callable, Literal
from typing import Callable
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
@@ -37,16 +37,21 @@ from superset.mcp_service.common.schema_discovery import (
DASHBOARD_DEFAULT_COLUMNS,
DASHBOARD_SEARCH_COLUMNS,
DASHBOARD_SORTABLE_COLUMNS,
DATABASE_DEFAULT_COLUMNS,
DATABASE_SEARCH_COLUMNS,
DATABASE_SORTABLE_COLUMNS,
DATASET_DEFAULT_COLUMNS,
DATASET_SEARCH_COLUMNS,
DATASET_SORTABLE_COLUMNS,
get_chart_columns,
get_dashboard_columns,
get_database_columns,
get_dataset_columns,
GetSchemaRequest,
GetSchemaResponse,
ModelSchemaInfo,
)
from superset.mcp_service.constants import ModelType
from superset.mcp_service.mcp_core import ModelGetSchemaCore
logger = logging.getLogger(__name__)
@@ -109,14 +114,36 @@ def _get_dashboard_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
)
def _get_database_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
"""Create database schema core with dynamically extracted columns."""
# Lazy import to avoid circular dependency at module load time
from superset.daos.database import DatabaseDAO
from superset.mcp_service.common.schema_discovery import DATABASE_EXCLUDE_COLUMNS
return ModelGetSchemaCore(
model_type="database",
dao_class=DatabaseDAO,
output_schema=ModelSchemaInfo,
select_columns=get_database_columns(),
sortable_columns=DATABASE_SORTABLE_COLUMNS,
default_columns=DATABASE_DEFAULT_COLUMNS,
search_columns=DATABASE_SEARCH_COLUMNS,
default_sort="changed_on",
default_sort_direction="desc",
exclude_filter_columns=DATABASE_EXCLUDE_COLUMNS,
logger=logger,
)
# Map model types to their core factory functions
_SCHEMA_CORE_FACTORIES: dict[
Literal["chart", "dataset", "dashboard"],
ModelType,
Callable[[], ModelGetSchemaCore[ModelSchemaInfo]],
] = {
"chart": _get_chart_schema_core,
"dataset": _get_dataset_schema_core,
"dashboard": _get_dashboard_schema_core,
"database": _get_database_schema_core,
}
@@ -143,7 +170,7 @@ async def get_schema(request: GetSchemaRequest, ctx: Context) -> GetSchemaRespon
Column metadata is extracted dynamically from SQLAlchemy models.
Args:
model_type: One of "chart", "dataset", or "dashboard"
model_type: One of "chart", "dataset", "dashboard", or "database"
Returns:
Comprehensive schema information for the requested model type

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