Compare commits

..
Author SHA1 Message Date
Claude Code 4debd2d01a fix(legacy-preset-chart-nvd3): sanitize tooltip HTML built from chart data
Several tooltip generators in this module build HTML strings from chart
data and return them to be rendered via D3 `.html()`, but did not run the
result through DOMPurify the way the sibling generators in the same file
already do. Apply `dompurify.sanitize()` to the returned HTML in
`generateBubbleTooltipContent`, `generateMultiLineTooltipContent`, and the
`tipFactory` annotation tooltip callback so data-derived values are
rendered as text rather than markup.

Adds regression tests asserting that script/handler markup in the
data-derived fields is stripped from the generated tooltip HTML.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:05:03 -07:00
Amin Ghadersohi 87be424f9c feat(mcp): add list and get tools for row level security and plugins (#40347) 2026-05-30 10:41:12 -04:00
Kasia 4d95a8d034 feat(listview): compact filter pills with popover for CRUD views (#40169) 2026-05-30 10:30:40 +02:00
Đỗ Trọng HảiandEvan Rusackas 2d6e68b5f2 fix(ci): remove deprecated ephemeral env workflows + resolve fixable GHA-related security issues (#40121)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-30 14:09:46 +07:00
Evan RusackasandClaude Code 2e7bec3646 chore(ci): harden GitHub Actions workflows per static analysis (#40545)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 13:13:43 +07:00
Evan RusackasandClaude Code f4787a4f25 chore(deps): bump ws to 8.20.1 in docs (#40538)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 13:08:13 +07:00
Evan RusackasandClaude Code fa4e571db5 chore(deps): force uuid 11.1.1 in docs (#40542)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 13:04:31 +07:00
Evan RusackasandClaude Code 838ee27c29 chore(deps): bump protobuf to 5.29.6 and google-cloud-bigquery-storage to 2.26.0 (#40537)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 11:49:15 +07:00
7f54b0b13d test(database): regression test for sqla engine creation (#27897) (#40237)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:47:49 -07:00
Evan RusackasandClaude Code f165c3fa78 fix(ci): grant security-events write to GHA validator workflow (#40539)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-29 21:46:54 -07:00
Evan RusackasandClaude Code 8c6271e9ff chore(deps): bump urllib3, Mako, and python-multipart for high-severity CVEs (#40534)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-30 11:39:33 +07:00
Evan RusackasandClaude Code 44a8d9d469 chore(deps): pin lodash, lodash-es, and yaml in docs to patched releases (#40535)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-29 21:26:39 -07:00
Amin Ghadersohi d350792d43 feat(mcp): add list and get tools for CSS templates and themes (#40343) 2026-05-30 00:20:48 -04:00
c9136af8b6 fix(ci): trigger python dep check when pyproject.toml changes (#39792)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-29 21:13:56 -07:00
David KopelentandEvan Rusackas f0838353a5 feat(i18n): add Czech (cs) locale support for dates (#40241)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-29 21:13:52 -07:00
16b56873b0 fix(ci): restrict workflow_run jobs to trusted origins and add zizmor audit (#40533)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-30 11:11:40 +07:00
Amin GhadersohiandClaude Sonnet 4.6 62b4ee3d9e feat(mcp): add list and get tools for users and roles (#40345)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 23:46:30 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 43c3c06035 chore(deps): bump vm2 from 3.11.3 to 3.11.5 in /superset-frontend (#40529)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 20:25:32 -07:00
Amin Ghadersohi 40de44f6de feat(mcp): add list and get tools for action log and tasks (#40344) 2026-05-29 23:16:10 -04:00
Đỗ Trọng Hải b8ea4448d6 feat(ci): perform static security analysis for GHA workflows (#40510)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-29 20:15:14 -07:00
Amin GhadersohiandClaude Sonnet 4.6 8d8eeb3505 feat(mcp): add list_tags and get_tag_info MCP tools (#40349)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:16:59 -04:00
Amin GhadersohiandClaude Sonnet 4.6 a69bbcb044 feat(mcp): add list and get tools for saved queries and query history (#40346)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:16:11 -04:00
Amin Ghadersohi f614863ed7 feat(mcp): add list and get tools for annotation layers and annotations (#40342) 2026-05-29 22:14:28 -04:00
53e2793bc3 fix: coerce out-of-bounds nanosecond timestamps to NaT instead of raising (#40127)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
2026-05-29 14:32:33 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Đỗ Trọng Hải
e73d2d0bf6 chore(deps): bump tmp from 0.2.4 to 0.2.7 in /superset-frontend/cypress-base (#40476)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-29 13:43:14 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenberĐỗ Trọng Hải
ae5823fb9c chore(deps-dev): bump tmp from 0.2.5 to 0.2.7 in /superset-frontend (#40480)
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>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-29 13:42:49 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 63e3a18e8f chore(deps): bump baseline-browser-mapping from 2.10.31 to 2.10.32 in /docs (#40484)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:42:32 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 661ff31c6d chore(deps): bump yeoman-generator from 8.1.2 to 8.2.2 in /superset-frontend (#40485)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:42:19 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ead21d9789 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40486)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:42:04 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c3bda6baea chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#40487)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:41:50 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 61cb0aeae7 chore(deps): bump @swc/core from 1.15.33 to 1.15.40 in /docs (#40515)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:41:35 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cf9ee99b5a chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 0.10.1 to 0.10.2 in /superset-frontend (#40516)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:41:22 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e1948c87c6 chore(deps-dev): bump webpack-sources from 3.4.1 to 3.5.0 in /superset-frontend (#40517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:41:08 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 63ae80ab62 chore(deps-dev): bump baseline-browser-mapping from 2.10.31 to 2.10.32 in /superset-frontend (#40518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:40:53 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8f88bbcc79 chore(deps-dev): bump @swc/core from 1.15.33 to 1.15.40 in /superset-frontend (#40519)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:40:39 -07:00
b2320820b4 feat(core): SoftDeleteMixin and restore infrastructure (#39977)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:08:10 -07:00
Mehmet Salih Yavuz 8853ab5c75 feat(mcp): add get_dashboard_layout companion tool (#40328) 2026-05-29 14:31:16 +03:00
Evan RusackasandClaude Code b0da0cf202 chore(ci): update GHA actions to Node.js 24-compatible versions (#40477)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-29 11:12:41 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 96b96ad7d4 chore(deps): bump ws from 8.20.1 to 8.21.0 in /superset-websocket (#40483)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 11:10:18 +07:00
Shrey Shekhar f7e1f96894 fix(i18n): Wrapped default Chat Error message in t() to support translation (#40504) 2026-05-29 11:04:15 +07:00
Nishita MatlaniandĐỗ Trọng Hải ec09cec6bd fix(ci): correct first-interaction inputs in welcome workflow (#40508)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-29 10:53:38 +07:00
ShaitanandClaude Sonnet 4.6 f663f47628 fix(embedding): require non-default JWT secret when embedded dashboards are enabled (#39999)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:26:40 -07:00
Abdul RehmanandĐỗ Trọng Hải 8a0026b173 fix(groups): display user full name in role edit user dropdown (#39942)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-28 14:25:45 -07:00
Elizabeth ThompsonandClaude Sonnet 4.6 f037449b75 fix(roles): resolve HTTP 429 and 414 errors on role management page (#39465)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:34:54 -07:00
Gabriel Torres Ruiz e68251fa70 feat(mcp): support custom SQL metrics in generate_chart and update_chart (#40448) 2026-05-28 14:52:43 -03:00
Amin GhadersohiandClaude Sonnet 4.6 0dc58d1042 feat(mcp): browser hello page with working middleware and config-driven content (#40471)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:47:38 -04:00
jesperct c73106b7a2 fix(chart-echarts): drop white textBorder from Funnel segment labels (#40468) 2026-05-27 23:59:51 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9441240e5c chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40436)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 15:28:56 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 08164e33bb chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#40437)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 15:28:41 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 894058fe3d chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#40438)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 15:28:24 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6bd1b46216 chore(deps): bump github/codeql-action from 4.35.5 to 4.36.0 (#40458)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 15:28:06 -07:00
Deva ef4514f5ab fix(treemap): remove gaps between chart nodes (#40181) 2026-05-27 22:35:06 +03:00
Amin Ghadersohi e041f25385 fix(mcp): return error when target_tab not found in add_chart_to_existing_dashboard (#40124) 2026-05-27 14:29:43 -04:00
d744f5715c fix(dashboard-export): include and re-attach roles in import/export (#21000) (#40136)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: SBIN2010 <Sbin2010@mail.ru>
2026-05-27 10:49:07 -07:00
Amin Ghadersohi fb60662353 chore(mcp): revert browser-friendly hello page for GET /mcp from browsers (#40467) 2026-05-27 11:43:01 -04:00
Onur TaşhanandClaude Sonnet 4.6 207a7bf7f9 fix: preserve dashboard certification when saving layout changes (#40193)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:03:43 -04:00
Amin GhadersohiandClaude Sonnet 4.6 09a94fa26b feat(mcp): return browser-friendly hello page for GET /mcp from browsers (#40309)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:52:37 -07:00
Mike BridgeandMike Bridge 7e088792b9 test(model): roll back uncommitted ds_col mutations in timestamp-expression tests (#40451)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-05-26 21:17:08 -03:00
Maxime BeaucheminandAmin Ghadersohi b6f545e61e feat(mcp): resolve call_tool proxy name and capture error_type in logging (#38915)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-05-26 14:37:37 -04:00
Amin Ghadersohi 952a6f3a23 fix(mcp): prevent encoding error on tools/list when middleware raises (#40446) 2026-05-26 12:50:31 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8b551d3f74 chore(deps-dev): bump duckdb from 1.4.2 to 1.5.2 (#40381)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 22:56:50 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 709ef9b615 chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#40321)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:08:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e9d46d843f chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40415)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:07:41 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9cc2deb903 chore(deps): update zod requirement from ^4.4.1 to ^4.4.3 in /superset-frontend/plugins/plugin-chart-echarts (#40416)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:07:27 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 03d25277ba chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#40417)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:07:14 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> bbe2f207d2 chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#40418)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 19:07:00 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Evan RusackasClaudeClaude Sonnet 4.6
c381677dfd chore(deps): bump click from 8.2.1 to 8.4.0 (#40312)
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: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:55:38 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>ClaudeClaude Sonnet 4.6Evan
09572cd5ef chore(deps): bump tabulate from 0.9.0 to 0.10.0 (#40315)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Evan <evan@preset.io>
2026-05-25 18:55:24 -07:00
Alexandru Soare 33585b0480 feat(mcp): make form_data_key optional in update_chart_preview (#39680) 2026-05-25 15:09:36 +03:00
Alexandru Soare b64561f3a3 chore(mcp): Simplify chart preview response (#40020) 2026-05-25 13:16:27 +03:00
xavier-GitHub76 fe484f6bb2 fix(UserListModal): Success notification mentions user and not group (#40284) 2026-05-25 00:03:14 +03:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>ClaudeClaude Sonnet 4.6
8caa74354f chore(deps): bump markdown from 3.8.1 to 3.10.2 (#40389)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:18:33 -07:00
9c90a6854c ci(translations): hard-block translation regressions in CI (#39443)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-23 16:30:14 -07:00
2fef4e41f2 feat(i18n): add Finnish (fi) translations (AI-generated, needs review) (#40390)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:29:44 -07:00
Jean Massucatto 965ec47296 fix(explore): hide value input for unary filter operators (#39924) 2026-05-23 11:43:49 -07:00
b21450681d feat(i18n): add Thai (th) translations (AI-generated, needs review) (#40391)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:35:19 -07:00
Torsten Stöter 5003ee1499 docs: remove out-of-place phrase (#40226) 2026-05-23 13:42:52 +07:00
Abdul RehmanandĐỗ Trọng Hải 816794b198 fix(frontend): handle null/undefined path in ensureAppRoot (#39940)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-23 13:22:27 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 841871f1e7 chore(deps): bump qs from 6.14.2 to 6.15.2 in /superset-websocket/utils/client-ws-app (#40382)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 12:00:20 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 55203bbc74 chore(deps): bump qs from 6.14.2 to 6.15.2 in /docs (#40383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 11:55:04 +07:00
2fa3bbd91c chore(ci): limit /app/prefix matrix variant to master merges (#40385)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:11:21 -07:00
168b49bf34 chore(cypress): remove dead _skip spec files and skipped inline tests (#40384)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:11:13 -07:00
838ac8f553 fix(ci): stop cancelling Hold Label Check runs (#40380)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:10:23 -07:00
Evan RusackasandClaude Code 42668cf634 ci(docker): pin QEMU binfmt image to stabilize arm64 builds (#40235)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-22 21:09:54 -07:00
Evan RusackasandClaude Code 8d985d223b ci(e2e): run backend under gunicorn instead of flask dev server (#40234)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-22 21:09:14 -07:00
e57387098b fix(bigquery): limit result set size to prevent browser memory crashes (#38588)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: ethan-l-geotab <ethanliong@geotab.com>
2026-05-22 21:07:45 -07:00
Evan RusackasClaude Codecodeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>Superset DevĐỗ Trọng HảiClaude
af6ac4d09c feat(i18n): AI-assisted translation backfill tooling + Spanish translations (#39448)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-22 21:07:27 -07:00
yousophandClaude Sonnet 4.6 f8e13770fc fix(dashboard): add top padding to "Create new chart" button in builder pane (#40033)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:37:04 -07:00
Miha RejecandMiha Rejec 5cdd542ae5 fix(i18n): translate DateFilter tooltip for time range values (#40286)
Co-authored-by: Miha Rejec <mihar@comland.si>
2026-05-23 00:22:31 +03:00
dependabot[bot]Claudedependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e40648dfcb chore(deps-dev): bump typescript from 5.9.3 to 6.0.3 in /superset-websocket (#39425)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:39:07 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Claude CodeClaude
c728b4a11f chore(deps): bump sqlglot from 28.10.0 to 30.8.0 (#40186)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-22 11:35:30 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Đỗ Trọng Hải
0febe32dc9 chore(deps): bump geostyler from 18.5.1 to 18.6.0 in /superset-frontend (#40323)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-22 11:34:30 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2a0ebd7055 chore(deps-dev): bump ts-jest from 29.4.10 to 29.4.11 in /superset-websocket (#40363)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:34:16 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6e23e4541d chore(deps): bump yeoman-generator from 8.1.2 to 8.2.2 in /superset-frontend (#40365)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:33:58 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5af8fe77fa chore(deps): bump zod from 4.4.1 to 4.4.3 in /superset-frontend (#40367)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:33:44 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3599c78a03 chore(deps): bump react-arborist from 3.6.1 to 3.7.0 in /superset-frontend (#40371)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:33:28 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d97b5d6509 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40372)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:33:12 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 869ab37f59 chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40366)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 11:28:14 -07:00
Joe LiandClaude Sonnet 4.6 3b4892c48c fix(select): replace cached options with search results in AsyncSelect (#40039)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:25:03 -07:00
Đỗ Trọng HảiandCopilot 91d96419fe feat(sec): delays version-bumping PR to avoid prematurely usage of compromised packages (#39783)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Copilot <copilot@github.com>
2026-05-22 11:01:10 -07:00
Ali GoutaandEvan Rusackas 42149f6a78 fix(chart): fix label and description translation on UI and enhance french translations (#40229)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-22 10:47:55 -07:00
c945ef6763 chore(oxlint): enable import/newline-after-import + react/no-unstable-nested-components (#40319)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:11:38 -07:00
Beto Dealmeida 21059b54f0 feat(semantic layers): form for SL with a single SV (#40280) 2026-05-22 12:04:25 -04:00
Mehmet Salih Yavuz 8ab4695ba3 fix(mcp): use name URL param so AI-generated SQL Lab titles render (#40288) 2026-05-22 17:35:08 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b0d26196fc chore(deps-dev): bump @swc/plugin-emotion from 14.9.0 to 14.10.0 in /superset-frontend (#40368)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 21:31:01 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> df8222ffcd chore(deps-dev): bump ts-jest from 29.4.10 to 29.4.11 in /superset-frontend (#40369)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 21:28:40 +07:00
Đỗ Trọng Hải 64f0e88de7 chore(backend/build): upgrade Gunicorn from v22 to v25 (#38788)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-22 21:24:58 +07:00
Đỗ Trọng Hải f4af6a2caf fix(docker): add missing service-worker.js into built container image (#39596)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-22 21:23:21 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 31087177ab chore(deps-dev): bump webpack from 5.107.0 to 5.107.1 in /docs (#40364)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 21:21:22 +07:00
Shaitan 8e98ca6569 docs: expand out-of-scope vulnerability definitions (#40332) 2026-05-22 21:20:57 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f7f6c29adf chore(deps-dev): bump webpack from 5.106.2 to 5.107.1 in /superset-frontend (#40370)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 21:15:44 +07:00
Miha RejecandMiha Rejec a94edfe418 fix(i18n): add Slovenian translation for 'Range type' in DateFilter (#40287)
Co-authored-by: Miha Rejec <mihar@comland.si>
2026-05-22 09:58:27 -04:00
David Kopelent 5549100601 feat(i18n): add missing Slovak translations (#40219) 2026-05-22 09:56:43 -04:00
Alexandru Soare 558ff4452b fix(preview): fix chart preview bugs (#40063) 2026-05-22 13:42:59 +03:00
Amin GhadersohiandClaude Sonnet 4.6 5966bb1c1e feat(mcp): add series_limit to generate_chart XY config (#40307)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:36:30 -04:00
chaselynisabella ac035083d7 feat(path): support metric-based color scales & line width by metric (#39165) 2026-05-21 19:31:15 -04:00
Amin GhadersohiandClaude Sonnet 4.6 e25d708197 fix(mcp): hide write tools from users without write permissions (#40098)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 19:11:46 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 48cb3f5885 chore(deps-dev): bump baseline-browser-mapping from 2.10.29 to 2.10.31 in /superset-frontend (#40320)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:45:02 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dcef6f8a41 chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40322)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:44:46 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f09fd63495 chore(deps): bump @googleapis/sheets from 13.0.1 to 13.0.2 in /superset-frontend (#40324)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:44:05 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> bc26006a43 chore(deps-dev): update sqlalchemy-drill requirement from <2,>=1.1.4 to >=1.1.10,<2 (#40310)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:42:40 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8b483f320e chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#40325)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 13:41:40 -07:00
Evan RusackasandClaude Opus 4.5 89c2a47433 fix(TableView): reset pagination when data reduces below current page (#34562)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 13:39:05 -07:00
JUST.in DO ITandCopilot b8b91574e0 fix(view query): Update style for code viewer container (#39635)
Co-authored-by: Copilot <copilot@github.com>
2026-05-21 13:37:56 -07:00
Jay Masiwal 5526464def fix(frontend): update safeStringify to surface [Circular] and DRY plugin code (#39156) 2026-05-21 13:37:05 -07:00
Evan RusackasandClaude Code 73f66e4c14 fix(datasets): isolate filter state to fix concurrent /dataset race (#39685)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-21 11:12:32 -07:00
Elizabeth Thompson f187a8e1c4 fix(reports): guard null dashboard height in Playwright screenshots (#40179) 2026-05-21 09:19:29 -07:00
Mehmet Salih Yavuz 4c3f65ef0b feat(mcp): make config optional in generate_explore_link (#39559) 2026-05-21 18:01:59 +03:00
Mehmet Salih YavuzandClaude Opus 4.7 53d8e5bdfa feat(mcp): include applied dashboard filters in get_chart_info (#39620)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:48:21 +03:00
Mehmet Salih YavuzandClaude Opus 4.7 2f95d288dd fix(mcp): eager-load dataset.metrics to prevent Excel export DetachedInstanceError (#39483)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:34:38 +03:00
Beto Dealmeida 2f5fcc21f9 fix(semantic layers): coerce filter types (#40222) 2026-05-21 09:25:27 -04:00
Mehmet Salih YavuzandClaude Opus 4.7 d1d07112aa feat(mcp): add find_users tool and owner filter columns for listings (#39679)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:59:09 +03:00
Alexandru Soare e3711bec39 fix(recommandation): Fix chart recommandation (#39886) 2026-05-21 15:16:16 +03:00
Mehmet Salih Yavuz ce9cab098f feat(mcp): chart formatting options across all supported chart types (#39887) 2026-05-21 15:00:32 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a183582291 chore(deps): bump markdown-to-jsx from 9.8.0 to 9.8.1 in /superset-frontend (#40316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:33:08 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3acef94ef6 chore(deps): update zod requirement from ^4.4.1 to ^4.4.3 in /superset-frontend/plugins/plugin-chart-echarts (#40313)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:32:46 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9638eecdb1 chore(deps-dev): bump oxlint from 1.65.0 to 1.66.0 in /superset-frontend (#40318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:29:45 -07:00
Evan RusackasandClaude 7e74fc4192 fix(charts): handle PostgreSQL INTERVAL type in bar and pie charts (#34513)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 22:26:59 -07:00
Evan RusackasandClaude Opus 4.7 cdca6f7fdc fix(sqllab): keep saved-query list working when Jinja dataset(id) references a deleted dataset (#39703)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:19:19 -07:00
b1ca8cac6b fix(tests): fix flaky FileHandler test by awaiting LaunchQueue consumer in afterEach (#39508)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: sadpandajoe <jcli38@gmail.com>
2026-05-20 19:31:01 -07:00
Evan RusackasandClaude Code 2cd5efa627 ci(deps): bump lower bound on pip dependabot PRs (#40308)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-21 08:53:57 +07:00
a273fe4d62 fix(list-view): preserve user name in filter pill after navigation (#39505)
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:54:49 -07:00
d203f0de33 chore(sql-lab): finish SqlLab typed-dispatch migration for SaveDatasetModal (#40040)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-20 16:04:38 -07:00
421 changed files with 100776 additions and 18350 deletions
+12 -4
View File
@@ -33,13 +33,21 @@ We kindly ask you to include the following information in your report to assist
- Expected vs. Actual Behavior: A clear description of the intended system behavior versus the observed vulnerability.
- Detailed Reproduction Steps: Clear, manual steps to reproduce the vulnerability.
**Vulnerability Definition**
Apache Superset considers a security vulnerability to be a demonstrable issue that has meaningful impact on confidentiality, integrity, or availability beyond the intended security model. Low-impact boundary variations or technical edge cases in existing access controls may be classified as hardening improvements rather than vulnerabilities, even if exploitable.
**Out of Scope Vulnerabilities**
To prioritize engineering efforts on genuine architectural risks, the following scenarios are explicitly out of scope and will not be issued a CVE:
- Attacks requiring Admin privileges: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
- Brute Force and Rate Limiting: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
- Theoretical attack vectors: Issues without a demonstrable, reproducible exploit path.
- Non-Exploitable Findings: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
- **Attacks requiring Admin privileges**: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
- **Brute Force and Rate Limiting**: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
- **Theoretical attack vectors**: Issues without a demonstrable, reproducible exploit path.
- **Non-Exploitable Findings**: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
- **User enumeration**: API responses, timing differences, or error messages that reveal whether user accounts, IDs, dashboards, or datasets exist.
- **Information disclosure (low impact)**: Software version disclosure, generic error messages, stack traces without sensitive data exposure, or system configuration details that don't enable further exploitation.
- **Resource exhaustion requiring authentication**: Denial of Service attacks that require valid user credentials and don't bypass rate limiting or resource controls.
- **Missing security headers**: Without demonstration of a concrete exploit scenario that leverages the missing header.
**Outcome of Reports**
+1 -1
View File
@@ -36,7 +36,7 @@ runs:
echo "PYTHON_VERSION=${{ inputs.python-version }}" >> $GITHUB_ENV
fi
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: ${{ inputs.cache }}
+12 -3
View File
@@ -26,16 +26,25 @@ runs:
- name: Set up QEMU
if: ${{ inputs.build == 'true' }}
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
with:
# Pin the binfmt image to a specific QEMU release. The default
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
# QEMU's x86_64→aarch64 translator has been the proximate cause of
# intermittent `exit code: 132` (SIGILL) failures during the arm64
# leg of the multi-platform docker build — newer Node native modules
# emit instructions QEMU's user-mode emulation occasionally drops on
# the floor. Pinning a known-good release stabilises that path.
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
if: ${{ inputs.build == 'true' }}
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Try to login to DockerHub
if: ${{ inputs.login-to-dockerhub == 'true' }}
continue-on-error: true
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ inputs.dockerhub-user }}
password: ${{ inputs.dockerhub-token }}
+3 -2
View File
@@ -10,7 +10,7 @@ runs:
steps:
- name: Setup Node Env
uses: actions/setup-node@v4
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
@@ -21,8 +21,9 @@ runs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
if: ${{ inputs.from-npm == 'false' }}
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
repository: apache-superset/supersetbot
path: supersetbot
+65 -1
View File
@@ -1,7 +1,6 @@
version: 2
enable-beta-ecosystems: true
updates:
- package-ecosystem: "github-actions"
directory: "/"
ignore:
@@ -10,6 +9,8 @@ updates:
- dependency-name: anthropics/claude-code-action
schedule:
interval: "daily"
cooldown:
default-days: 7
- package-ecosystem: "npm"
ignore:
@@ -57,16 +58,25 @@ updates:
- dependabot
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "pip"
directory: "/"
open-pull-requests-limit: 10
# Bump the lower bound to the new version, not just widen the upper
# bound. Without this, a `sqlglot>=28.10.0, <29` constraint upgraded
# to `<30` would keep the stale lower bound forever, dragging
# transitively-resolved versions with it. See #40186 (review thread).
versioning-strategy: increase
schedule:
interval: "weekly"
labels:
- pip
- dependabot
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: ".github/actions"
@@ -74,6 +84,8 @@ updates:
interval: "daily"
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/docs/"
@@ -97,6 +109,8 @@ updates:
interval: "daily"
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-websocket/"
@@ -106,6 +120,8 @@ updates:
- npm
- dependabot
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-websocket/utils/client-ws-app/"
@@ -116,6 +132,8 @@ updates:
- dependabot
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
# Now for all of our plugins and packages!
@@ -128,6 +146,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
@@ -138,6 +158,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
@@ -148,6 +170,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
@@ -161,6 +185,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
@@ -171,6 +197,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
@@ -181,6 +209,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
@@ -191,6 +221,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
@@ -201,6 +233,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-table/"
@@ -214,6 +248,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
@@ -224,6 +260,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
@@ -234,6 +272,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
@@ -244,6 +284,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
@@ -254,6 +296,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
@@ -264,6 +308,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
@@ -274,6 +320,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
@@ -284,6 +332,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
@@ -294,6 +344,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
@@ -304,6 +356,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
@@ -318,6 +372,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/generator-superset/"
@@ -328,6 +384,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
@@ -338,6 +396,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-core/"
@@ -353,6 +413,8 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-switchboard/"
@@ -363,3 +425,5 @@ updates:
- dependabot
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
+91 -32
View File
@@ -175,10 +175,13 @@ cypress-run-all() {
local APP_ROOT=$2
cd "$GITHUB_WORKSPACE/superset-frontend/cypress-base"
# Start Flask and run it in background
# --no-debugger means disable the interactive debugger on the 500 page
# so errors can print to stderr.
local flasklog="${HOME}/flask.log"
# Start the Superset backend via gunicorn (not `flask run`). The Flask
# development server is single-threaded and has no crash-recovery, so
# heavy tests (dashboard import/export, SQL Lab) can knock it offline
# for the rest of the run — surfacing as `ECONNREFUSED` / `socket hang up`
# / `Missing CSRF token` cascades. Gunicorn gives us multiple workers,
# a request timeout, and worker-recycling under load.
local serverlog="${HOME}/superset-cypress.log"
local port=8081
CYPRESS_BASE_URL="http://localhost:${port}"
if [ -n "$APP_ROOT" ]; then
@@ -187,8 +190,58 @@ cypress-run-all() {
fi
export CYPRESS_BASE_URL
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
local flaskProcessId=$!
# Mirrors the args in docker/entrypoints/run-server.sh (1 worker × 20
# gthread threads) to keep parity with production. Multi-worker
# configurations expose timing-sensitive races in the SQL Lab → Explore
# navigation flow under E2E. We diverge from the entrypoint on:
# --timeout 120: heavy dashboard import/export specs exceed the 60s
# default
# --max-requests / --max-requests-jitter: recycle the worker under
# test load to avoid leaks accumulating across the run
# superset.app:create_app(): explicit factory so we don't depend on
# FLASK_APP being exported
nohup gunicorn \
--bind "127.0.0.1:$port" \
--workers 1 \
--worker-class gthread \
--threads 20 \
--timeout 120 \
--max-requests 500 \
--max-requests-jitter 50 \
--access-logfile - \
--error-logfile - \
"superset.app:create_app()" \
>"$serverlog" 2>&1 </dev/null &
local serverPid=$!
# Ensure the backend is cleaned up and its log is emitted even when the
# test runner fails under `set -e`.
trap '
echo "::group::gunicorn log for Cypress run"
cat "'"$serverlog"'" || true
echo "::endgroup::"
kill '"$serverPid"' 2>/dev/null || true
' EXIT
# Wait for the backend to be ready before launching Cypress; otherwise
# the first spec can race the server bind and see connection errors.
local timeout=60
say "Waiting for gunicorn server to start on port $port..."
while [ $timeout -gt 0 ]; do
if curl -f "http://localhost:${port}${APP_ROOT}/health" >/dev/null 2>&1; then
say "gunicorn server is ready"
break
fi
sleep 1
timeout=$((timeout - 1))
done
if [ $timeout -eq 0 ]; then
echo "::error::gunicorn server failed to start within 60 seconds"
echo "::group::Server startup log"
cat "$serverlog"
echo "::endgroup::"
return 1
fi
USE_DASHBOARD_FLAG=''
if [ "$USE_DASHBOARD" = "true" ]; then
@@ -200,13 +253,6 @@ cypress-run-all() {
# memoryMonitorPid=$!
python ../../scripts/cypress_run.py --parallelism $PARALLELISM --parallelism-id $PARALLEL_ID --group $PARALLEL_ID --retries 5 $USE_DASHBOARD_FLAG
# kill $memoryMonitorPid
# After job is done, print out Flask log for debugging
echo "::group::Flask log for default run"
cat "$flasklog"
echo "::endgroup::"
# make sure the program exits
kill $flaskProcessId
}
playwright-install() {
@@ -224,9 +270,11 @@ playwright-run() {
local APP_ROOT=$1
local TEST_PATH=$2
# Start Flask from the project root (same as Cypress)
# Start the Superset backend via gunicorn from the project root.
# See cypress-run-all() above for the rationale — the Flask dev server
# cannot survive the dashboard import/export tests under load.
cd "$GITHUB_WORKSPACE"
local flasklog="${HOME}/flask-playwright.log"
local serverlog="${HOME}/superset-playwright.log"
local port=8081
PLAYWRIGHT_BASE_URL="http://localhost:${port}"
if [ -n "$APP_ROOT" ]; then
@@ -235,18 +283,37 @@ playwright-run() {
fi
export PLAYWRIGHT_BASE_URL
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
local flaskProcessId=$!
# See cypress-run-all() above for the args rationale (1 worker × 20
# gthread threads matching docker/entrypoints/run-server.sh, plus a
# 120s timeout and request-recycling for heavy E2E load).
nohup gunicorn \
--bind "127.0.0.1:$port" \
--workers 1 \
--worker-class gthread \
--threads 20 \
--timeout 120 \
--max-requests 500 \
--max-requests-jitter 50 \
--access-logfile - \
--error-logfile - \
"superset.app:create_app()" \
>"$serverlog" 2>&1 </dev/null &
local serverPid=$!
# Ensure cleanup on exit
trap "kill $flaskProcessId 2>/dev/null || true" EXIT
# Ensure cleanup on exit (and emit the server log on failure)
trap '
echo "::group::gunicorn log for Playwright run"
cat "'"$serverlog"'" || true
echo "::endgroup::"
kill '"$serverPid"' 2>/dev/null || true
' EXIT
# Wait for server to be ready with health check
local timeout=60
say "Waiting for Flask server to start on port $port..."
say "Waiting for gunicorn server to start on port $port..."
while [ $timeout -gt 0 ]; do
if curl -f ${PLAYWRIGHT_BASE_URL}/health >/dev/null 2>&1; then
say "Flask server is ready"
say "gunicorn server is ready"
break
fi
sleep 1
@@ -254,9 +321,9 @@ playwright-run() {
done
if [ $timeout -eq 0 ]; then
echo "::error::Flask server failed to start within 60 seconds"
echo "::group::Flask startup log"
cat "$flasklog"
echo "::error::gunicorn server failed to start within 60 seconds"
echo "::group::Server startup log"
cat "$serverlog"
echo "::endgroup::"
return 1
fi
@@ -271,7 +338,6 @@ playwright-run() {
if ! find "playwright/tests/${TEST_PATH}" -name "*.spec.ts" -type f 2>/dev/null | grep -q .; then
echo "No test files found in ${TEST_PATH} - skipping test run"
say "::endgroup::"
kill $flaskProcessId
return 0
fi
echo "Running tests: ${TEST_PATH}"
@@ -288,13 +354,6 @@ playwright-run() {
fi
say "::endgroup::"
# After job is done, print out Flask log for debugging
echo "::group::Flask log for Playwright run"
cat "$flasklog"
echo "::endgroup::"
# make sure the program exits
kill $flaskProcessId
return $status
}
-43
View File
@@ -1,43 +0,0 @@
name: Cancel Duplicates
on:
workflow_run:
workflows:
- "Miscellaneous"
types:
- requested
jobs:
cancel-duplicate-runs:
name: Cancel duplicate workflow runs
runs-on: ubuntu-24.04
permissions:
actions: write
contents: read
steps:
- name: Check number of queued tasks
id: check_queued
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPO: ${{ github.repository }}
run: |
get_count() {
echo $(curl -s -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$GITHUB_REPO/actions/runs?status=$1" | \
jq ".total_count")
}
count=$(( `get_count queued` + `get_count in_progress` ))
echo "Found $count unfinished jobs."
echo "count=$count" >> $GITHUB_OUTPUT
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
if: steps.check_queued.outputs.count >= 20
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Cancel duplicate workflow runs
if: steps.check_queued.outputs.count >= 20
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
pip install click requests typing_extensions python-dateutil
python ./scripts/cancel_github_workflows.py
@@ -26,6 +26,8 @@ jobs:
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check and notify
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+4
View File
@@ -6,6 +6,9 @@ on:
pull_request_review_comment:
types: [created]
permissions:
contents: read
jobs:
check-permissions:
if: |
@@ -75,6 +78,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude PR Action
+4 -2
View File
@@ -32,6 +32,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
@@ -41,7 +43,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,6 +55,6 @@ jobs:
- name: Perform CodeQL Analysis
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
category: "/language:${{matrix.language}}"
+4
View File
@@ -28,6 +28,8 @@ jobs:
steps:
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: "Dependency Review"
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
continue-on-error: true
@@ -50,6 +52,8 @@ jobs:
steps:
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Setup Python
uses: ./.github/actions/setup-backend/
+5 -1
View File
@@ -95,7 +95,11 @@ jobs:
# in the context of push (using multi-platform build), we need to pull the image locally
- name: Docker pull
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
run: docker pull $IMAGE_TAG
run: |
for i in 1 2 3; do
docker pull $IMAGE_TAG && break
[ $i -lt 3 ] && sleep 30
done
- name: Print docker stats
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
@@ -34,6 +34,8 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-embedded-sdk/.nvmrc'
+2
View File
@@ -22,6 +22,8 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-embedded-sdk/.nvmrc'
@@ -1,83 +0,0 @@
name: Cleanup ephemeral envs (PR close) [DEPRECATED]
# ⚠️ DEPRECATION NOTICE ⚠️
# This workflow is deprecated and will be removed in a future version.
# The new Superset Showtime workflow handles cleanup automatically.
# See .github/workflows/showtime.yml and showtime-cleanup.yml for replacements.
# Migration guide: https://github.com/mistercrunch/superset-showtime
on:
pull_request_target:
types: [closed]
jobs:
config:
runs-on: ubuntu-24.04
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
steps:
- name: "Check for secrets"
id: check
shell: bash
run: |
if [ -n "${AWS_ACCESS_KEY_ID}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
env:
AWS_ACCESS_KEY_ID: ${{ (secrets.AWS_ACCESS_KEY_ID != '' && secrets.AWS_SECRET_ACCESS_KEY != '') || '' }}
ephemeral-env-cleanup:
needs: config
if: needs.config.outputs.has-secrets
name: Cleanup ephemeral envs
runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Describe ECS service
id: describe-services
run: |
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${{ github.event.number }}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
- name: Delete ECS service
if: steps.describe-services.outputs.active == 'true'
id: delete-service
run: |
aws ecs delete-service \
--cluster superset-ci \
--service pr-${{ github.event.number }}-service \
--force
- name: Login to Amazon ECR
if: steps.describe-services.outputs.active == 'true'
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Delete ECR image tag
if: steps.describe-services.outputs.active == 'true'
id: delete-image-tag
run: |
aws ecr batch-delete-image \
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
--repository-name superset-ci \
--image-ids imageTag=pr-${{ github.event.number }}
- name: Comment (success)
if: steps.describe-services.outputs.active == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
github.rest.issues.createComment({
issue_number: ${{ github.event.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: '⚠️ **DEPRECATED WORKFLOW** - Ephemeral environment shutdown and build artifacts deleted. Please migrate to the new Superset Showtime system for future PRs.'
})
-350
View File
@@ -1,350 +0,0 @@
name: Ephemeral env workflow [DEPRECATED]
# ⚠️ DEPRECATION NOTICE ⚠️
# This workflow is deprecated and will be removed in a future version.
# Please use the new Superset Showtime workflow instead:
# - Use label "🎪 trigger-start" instead of "testenv-up"
# - Showtime provides better reliability and easier management
# - See .github/workflows/showtime.yml for the replacement
# - Migration guide: https://github.com/mistercrunch/superset-showtime
# Example manual trigger:
# gh workflow run ephemeral-env.yml --ref fix_ephemerals --field label_name="testenv-up" --field issue_number=666
on:
pull_request_target:
types:
- labeled
workflow_dispatch:
inputs:
label_name:
description: 'Label name to simulate label-based /testenv trigger'
required: true
default: 'testenv-up'
issue_number:
description: 'Issue or PR number'
required: true
jobs:
ephemeral-env-label:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-label
cancel-in-progress: true
name: Evaluate ephemeral env label trigger
runs-on: ubuntu-24.04
permissions:
pull-requests: write
outputs:
slash-command: ${{ steps.eval-label.outputs.result }}
feature-flags: ${{ steps.eval-feature-flags.outputs.result }}
sha: ${{ steps.get-sha.outputs.sha }}
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Check for the "testenv-up" label
id: eval-label
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
LABEL_NAME="${INPUT_LABEL_NAME}"
else
LABEL_NAME="${{ github.event.label.name }}"
fi
echo "Evaluating label: $LABEL_NAME"
if [[ "$LABEL_NAME" == "testenv-up" ]]; then
echo "result=up" >> $GITHUB_OUTPUT
else
echo "result=noop" >> $GITHUB_OUTPUT
fi
env:
INPUT_LABEL_NAME: ${{ github.event.inputs.label_name }}
- name: Get event SHA
id: get-sha
if: steps.eval-label.outputs.result == 'up'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
let prSha;
// If event is workflow_dispatch, use the issue_number from inputs
if (context.eventName === "workflow_dispatch") {
const prNumber = "${{ github.event.inputs.issue_number }}";
if (!prNumber) {
console.log("No PR number found.");
return;
}
// Fetch PR details using the provided issue_number
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
prSha = pr.head.sha;
} else {
// If it's not workflow_dispatch, use the PR head sha from the event
prSha = context.payload.pull_request.head.sha;
}
console.log(`PR SHA: ${prSha}`);
core.setOutput("sha", prSha);
- name: Looking for feature flags in PR description
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: eval-feature-flags
if: steps.eval-label.outputs.result == 'up'
with:
script: |
const description = context.payload.pull_request
? context.payload.pull_request.body || ''
: context.payload.inputs.pr_description || '';
const pattern = /FEATURE_(\w+)=(\w+)/g;
let results = [];
[...description.matchAll(pattern)].forEach(match => {
const config = {
name: `SUPERSET_FEATURE_${match[1]}`,
value: match[2],
};
results.push(config);
});
return results;
- name: Reply with confirmation comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
if: steps.eval-label.outputs.result == 'up'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const action = '${{ steps.eval-label.outputs.result }}';
const user = context.actor;
const runId = context.runId;
const workflowUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const issueNumber = context.payload.pull_request
? context.payload.pull_request.number
: context.payload.inputs.issue_number;
if (!issueNumber) {
throw new Error("Issue number is not available.");
}
const body = `⚠️ **DEPRECATED WORKFLOW** ⚠️\n\n@${user} This workflow is deprecated! Please use the new **Superset Showtime** system instead:\n\n` +
`- Replace "testenv-up" label with "🎪 trigger-start"\n` +
`- Better reliability and easier management\n` +
`- See https://github.com/mistercrunch/superset-showtime for details\n\n` +
`Processing your ephemeral environment request [here](${workflowUrl}). Action: **${action}**.` +
` More information on [how to use or configure ephemeral environments]` +
`(https://superset.apache.org/docs/contributing/howtos/#github-ephemeral-environments)`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
ephemeral-docker-build:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-build
cancel-in-progress: true
needs: ephemeral-env-label
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
name: ephemeral-docker-build
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ needs.ephemeral-env-label.outputs.sha }} : ${{steps.get-sha.outputs.sha}} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.ephemeral-env-label.outputs.sha }}
persist-credentials: false
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
build: "true"
install-docker-compose: "false"
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Build ephemeral env image
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
supersetbot docker \
--push \
--load \
--preset ci \
--platform linux/amd64 \
--context-ref "$RELEASE" \
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Load, tag and push image to ECR
id: push-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: superset-ci
IMAGE_TAG: apache/superset:${{ needs.ephemeral-env-label.outputs.sha }}-ci
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
docker tag $IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:pr-$PR_NUMBER-ci
docker push -a $ECR_REGISTRY/$ECR_REPOSITORY
ephemeral-env-up:
needs: [ephemeral-env-label, ephemeral-docker-build]
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
name: Spin up an ephemeral environment
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Check target image exists in ECR
id: check-image
continue-on-error: true
env:
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
aws ecr describe-images \
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
--repository-name superset-ci \
--image-ids imageTag=pr-$PR_NUMBER-ci
- name: Fail on missing container image
if: steps.check-image.outcome == 'failure'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ github.token }}
script: |
const errMsg = '@${{ github.event.comment.user.login }} Container image not yet published for this PR. Please try again when build is complete.';
github.rest.issues.createComment({
issue_number: ${{ github.event.inputs.issue_number || github.event.pull_request.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: errMsg
});
core.setFailed(errMsg);
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@6853cfae8c3a7d978fbf68b5a55453395541dfbb # v1
with:
task-definition: .github/workflows/ecs-task-definition.json
container-name: superset-ci
image: ${{ steps.login-ecr.outputs.registry }}/superset-ci:pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-ci
- name: Update env vars in the Amazon ECS task definition
run: |
cat <<< "$(jq '.containerDefinitions[0].environment += ${{ needs.ephemeral-env-label.outputs.feature-flags }}' < ${{ steps.task-def.outputs.task-definition }})" > ${{ steps.task-def.outputs.task-definition }}
- name: Describe ECS service
id: describe-services
run: |
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${INPUT_ISSUE_NUMBER}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
env:
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
- name: Create ECS service
id: create-service
if: steps.describe-services.outputs.active != 'true'
env:
ECR_SUBNETS: subnet-0e15a5034b4121710,subnet-0e8efef4a72224974
ECR_SECURITY_GROUP: sg-092ff3a6ae0574d91
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
aws ecs create-service \
--cluster superset-ci \
--service-name pr-$PR_NUMBER-service \
--task-definition superset-ci \
--launch-type FARGATE \
--desired-count 1 \
--platform-version LATEST \
--network-configuration "awsvpcConfiguration={subnets=[$ECR_SUBNETS],securityGroups=[$ECR_SECURITY_GROUP],assignPublicIp=ENABLED}" \
--tags key=pr,value=$PR_NUMBER key=github_user,value=${{ github.actor }}
- name: Deploy Amazon ECS task definition
id: deploy-task
uses: aws-actions/amazon-ecs-deploy-task-definition@a310a830f5c14e583e35d84e4e1ec7dd177c3c9c # v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service
cluster: superset-ci
wait-for-service-stability: true
wait-for-minutes: 10
- name: List tasks
id: list-tasks
run: |
echo "task=$(aws ecs list-tasks --cluster superset-ci --service-name pr-${INPUT_ISSUE_NUMBER}-service | jq '.taskArns | first')" >> $GITHUB_OUTPUT
env:
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
- name: Get network interface
id: get-eni
run: |
echo "eni=$(aws ecs describe-tasks --cluster superset-ci --tasks ${{ steps.list-tasks.outputs.task }} | jq '.tasks[0].attachments[0].details | map(select(.name=="networkInterfaceId"))[0].value')" >> $GITHUB_OUTPUT
- name: Get public IP
id: get-ip
run: |
echo "ip=$(aws ec2 describe-network-interfaces --network-interface-ids ${{ steps.get-eni.outputs.eni }} | jq -r '.NetworkInterfaces | first | .Association.PublicIp')" >> $GITHUB_OUTPUT
- name: Comment (success)
if: ${{ success() }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `@${{ github.actor }} Ephemeral environment spinning up at http://${{ steps.get-ip.outputs.ip }}:8080. Credentials are 'admin'/'admin'. Please allow several minutes for bootstrapping and startup.`
});
- name: Comment (failure)
if: ${{ failure() }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@${{ github.event.inputs.user_login || github.event.comment.user.login }} Ephemeral environment creation failed. Please check the Actions logs for details.'
})
+14 -3
View File
@@ -6,7 +6,8 @@ on:
- "master"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
branches:
- "**"
permissions:
contents: read
@@ -15,12 +16,19 @@ jobs:
validate-all-ghas:
runs-on: ubuntu-24.04
permissions:
contents: read
# Required for the zizmor action to upload its SARIF results to
# GitHub code scanning (advanced-security is enabled by default).
security-events: write
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
@@ -29,3 +37,6 @@ jobs:
- name: Run Script
run: bash .github/workflows/github-action-validator.sh
- name: Check for security issues on GHA workflows
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-24.04
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with:
sync-labels: true
+3 -1
View File
@@ -20,7 +20,9 @@ jobs:
- name: Check for latest tag
id: latest-tag
run: |
source ./scripts/tag_latest_release.sh $(echo ${{ github.event.release.tag_name }}) --dry-run
source ./scripts/tag_latest_release.sh $(echo ${GITHUB_EVENT_RELEASE_TAG_NAME}) --dry-run
env:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
- name: Configure Git
run: |
+5 -2
View File
@@ -7,10 +7,13 @@ on:
permissions:
pull-requests: read
# cancel previous workflow jobs for PRs
# Let each label event run to completion. Cancelling in-progress runs leaves
# CANCELLED entries in the PR's check-suite rollup, which poisons GitHub's
# `status:success` search filter even though all real CI passed. The job is
# a tiny no-op github-script call, so the wasted compute is negligible.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
cancel-in-progress: false
jobs:
check-hold-label:
+6
View File
@@ -6,6 +6,9 @@ on:
- "master"
- "[0-9].[0-9]*"
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -27,9 +30,12 @@ jobs:
if: needs.config.outputs.has-secrets
name: Bump version and publish package(s)
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# pulls all commits (needed for lerna / semantic release to correctly version)
fetch-depth: 0
- name: Get tags and filter trigger tags
+1 -1
View File
@@ -102,7 +102,7 @@ jobs:
- name: Install Superset Showtime
if: steps.auth.outputs.authorized == 'true'
run: |
echo "::notice::Maintainer ${{ github.actor }} triggered deploy for PR ${PULL_REQUEST_NUMBER}"
echo "::notice::Maintainer ${GITHUB_ACTOR} triggered deploy for PR ${PULL_REQUEST_NUMBER}"
pip install --upgrade superset-showtime
showtime version
+10 -1
View File
@@ -27,6 +27,9 @@ concurrency:
group: docs-deploy-asf-site
cancel-in-progress: true
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -45,7 +48,13 @@ jobs:
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
build-deploy:
needs: config
if: needs.config.outputs.has-secrets
# For workflow_run triggers, only deploy when the triggering run originated
# from this repository (not a fork), ensuring the checked-out code and any
# local actions executed with deploy credentials are trusted.
if: >-
needs.config.outputs.has-secrets &&
(github.event_name != 'workflow_run' ||
github.event.workflow_run.head_repository.full_name == github.repository)
name: Build & Deploy
runs-on: ubuntu-24.04
steps:
+7 -1
View File
@@ -16,6 +16,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_sha || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
linkinator:
# See docs here: https://github.com/marketplace/actions/linkinator
@@ -25,6 +28,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
@@ -97,7 +102,8 @@ jobs:
# Only runs if integration tests succeeded
if: >
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success'
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
name: Build (after integration tests)
runs-on: ubuntu-24.04
defaults:
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
matrix:
parallel_id: [0, 1, 2, 3, 4, 5]
browser: ["chrome"]
app_root: ["", "/app/prefix"]
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -161,7 +161,7 @@ jobs:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ["", "/app/prefix"]
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -53,7 +53,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: steps.check.outputs.superset-extensions-cli
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
file: ./coverage.xml
flags: superset-extensions-cli
+4 -1
View File
@@ -16,6 +16,9 @@ concurrency:
env:
TAG: apache/superset:GHA-${{ github.run_id }}
permissions:
contents: read
jobs:
frontend-build:
runs-on: ubuntu-24.04
@@ -128,7 +131,7 @@ jobs:
run: npx nyc merge coverage/ merged-output/coverage-summary.json
- name: Upload Code Coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: javascript
use_oidc: true
@@ -70,7 +70,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,mysql
verbose: true
@@ -164,7 +164,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,postgres
verbose: true
@@ -219,7 +219,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,sqlite
verbose: true
@@ -79,7 +79,7 @@ jobs:
run: |
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,presto
verbose: true
@@ -150,7 +150,7 @@ jobs:
pip install -e .[hive]
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,hive
verbose: true
@@ -56,7 +56,7 @@ jobs:
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: python,unit
verbose: true
@@ -0,0 +1,87 @@
name: Translation Regression Comment
on:
workflow_run:
workflows: ["Translations"]
types: [completed]
# This workflow posts a PR comment when the Translations workflow detects a
# regression. It uses the workflow_run trigger so that it always runs in the
# base-branch context and can safely be granted write permissions, even for
# PRs from forks.
#
# IMPORTANT: This workflow must NEVER check out code from the PR branch.
# All data comes from the artifact uploaded by the Translations workflow.
permissions:
pull-requests: write
actions: read
jobs:
post-comment:
runs-on: ubuntu-24.04
# Only act when the Translations workflow failed (which means a regression
# was detected — the workflow exits 1 on regression).
if: github.event.workflow_run.conclusion == 'failure'
steps:
- name: Download regression artifact
id: download
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: translation-regression
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: /tmp/translation-regression
- name: Post or update PR comment
if: steps.download.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
const prNumberFile = '/tmp/translation-regression/pr-number.txt';
const reportFile = '/tmp/translation-regression/regression-report.md';
if (!fs.existsSync(prNumberFile) || !fs.existsSync(reportFile)) {
console.log('Artifact files not found, skipping comment.');
return;
}
const prNumber = parseInt(fs.readFileSync(prNumberFile, 'utf8').trim(), 10);
if (!prNumber) {
console.log('Could not parse PR number, skipping comment.');
return;
}
const report = fs.readFileSync(reportFile, 'utf8');
const marker = '<!-- translation-regression-bot -->';
const body = `${marker}\n${report}`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
console.log(`Updated existing comment ${existing.id} on PR #${prNumber}`);
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
console.log(`Created new comment on PR #${prNumber}`);
}
+85 -5
View File
@@ -20,6 +20,9 @@ concurrency:
jobs:
frontend-check-translations:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -51,12 +54,16 @@ jobs:
babel-extract:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
submodules: recursive
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
@@ -64,12 +71,85 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
if: steps.check.outputs.python
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
uses: ./.github/actions/setup-backend/
- name: Install msgcat
run: sudo apt update && sudo apt install gettext
- name: Install gettext tools
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: sudo apt-get update && sudo apt-get install -y gettext
- name: Test babel extraction
if: steps.check.outputs.python
# Fetch the base ref so we can compare PR-introduced regressions
# against a fair baseline (also runs babel_update against the base
# source) — this isolates the PR's contribution from any pre-existing
# drift on the base branch.
- name: Fetch base ref and create comparison worktree
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: |
# For PRs use the base branch; for direct pushes compare against the previous commit.
BASE_REF="${{ github.event.pull_request.base.ref }}"
if [ -n "$BASE_REF" ]; then
git fetch --depth=1 origin "$BASE_REF"
else
git fetch --depth=2 origin "${{ github.ref }}"
fi
git worktree add /tmp/base-worktree FETCH_HEAD
# Run babel_update against BASE source + BASE translations. Any drift
# already present on the base branch (source strings that have changed
# without .po updates) shows up here as fuzzies — and will also show
# up in the PR run, so it cancels out in the comparison.
- name: Baseline — run babel_update against BASE source
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
working-directory: /tmp/base-worktree
run: ./scripts/translations/babel_update.sh
- name: Record baseline translation counts
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: |
python scripts/translations/check_translation_regression.py \
--count \
--translations-dir /tmp/base-worktree/superset/translations \
> /tmp/before.json
# Reset the PR worktree's translations to the pristine BASE state so
# both babel_update runs start from the same .po files. The only
# difference between the runs is the source code.
- name: Reset PR worktree translations to pristine BASE
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: git checkout FETCH_HEAD -- superset/translations/
- name: Run babel_update against PR source
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: ./scripts/translations/babel_update.sh
- name: Check for translation regression
id: regression
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
continue-on-error: true
run: |
python scripts/translations/check_translation_regression.py \
--compare /tmp/before.json \
--report /tmp/regression-report.md
# Save the PR number so the comment workflow can post the report without
# needing write permissions on this pull_request-triggered job.
- name: Save PR number for comment workflow
if: >-
github.event_name == 'pull_request' &&
steps.regression.outcome == 'failure'
run: echo "${{ github.event.pull_request.number }}" > /tmp/pr-number.txt
- name: Upload regression artifact
if: >-
github.event_name == 'pull_request' &&
steps.regression.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: translation-regression
path: |
/tmp/regression-report.md
/tmp/pr-number.txt
- name: Fail if regression detected
if: steps.regression.outcome == 'failure'
run: exit 1
+11 -2
View File
@@ -21,6 +21,9 @@ on:
options:
- 'true'
- 'false'
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -42,6 +45,8 @@ jobs:
if: needs.config.outputs.has-secrets
name: docker-release
runs-on: ubuntu-24.04
permissions:
contents: write
strategy:
matrix:
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
@@ -51,6 +56,7 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 0
- name: Setup Docker Environment
@@ -77,8 +83,9 @@ jobs:
INPUT_RELEASE: ${{ github.event.inputs.release }}
INPUT_FORCE_LATEST: ${{ github.event.inputs.force-latest }}
INPUT_GIT_REF: ${{ github.event.inputs.git-ref }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
RELEASE="${{ github.event.release.tag_name }}"
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
FORCE_LATEST=""
EVENT="${{github.event_name}}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
@@ -114,6 +121,7 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 0
- name: Use Node.js 20
@@ -128,11 +136,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_RELEASE: ${{ github.event.inputs.release }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
export GITHUB_ACTOR=""
git fetch --all --tags
git checkout master
RELEASE="${{ github.event.release.tag_name }}"
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# in the case of a manually-triggered run, read release from input
RELEASE="${INPUT_RELEASE}"
+2
View File
@@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+9 -4
View File
@@ -12,11 +12,16 @@ jobs:
steps:
- name: Welcome Message
uses: actions/first-interaction@v3
continue-on-error: true
uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
with:
repo-token: ${{ github.token }}
pr-message: |-
repo_token: ${{ github.token }}
issue_message: |-
Congrats on opening your first issue and thank you for contributing to Superset! :tada: :heart:
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
pr_message: |-
Congrats on making your first PR and thank you for contributing to Superset! :tada: :heart:
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
We hope to see you in our [Slack](https://apache-superset.slack.com/) community too! Not signed up? Use our [Slack App](http://bit.ly/join-superset-slack) to self-register.
+2
View File
@@ -115,6 +115,8 @@ release.json
superset/translations/**/messages.json
# these mo binary files are generated by `pybabel compile`
superset/translations/**/messages.mo
# cross-language index generated by scripts/translations/build_translation_index.py
superset/translations/translation_index.json
docker/requirements-local.txt
+12 -1
View File
@@ -50,7 +50,7 @@ repos:
hooks:
- id: check-docstring-first
- id: check-added-large-files
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$|^superset/translations/.*\.po$
- id: check-yaml
exclude: ^helm/superset/templates/
- id: debug-statements
@@ -158,3 +158,14 @@ repos:
language: system
files: ^superset/config\.py$
pass_filenames: false
- id: zizmor
name: zizmor (GHA security audit)
entry: zizmor
language: python
additional_dependencies: [zizmor==1.25.2]
files: ^\.github/
types: [yaml]
pass_filenames: false
# Advisory until pre-existing findings are resolved; remove
# --no-exit-codes to make this hook blocking.
args: [--no-exit-codes, .github/]
+1 -1
View File
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
[MESSAGES CONTROL]
disable=all
enable=json-import,disallowed-sql-import,consider-using-transaction
enable=disallowed-sql-import,consider-using-transaction
[REPORTS]
+3 -1
View File
@@ -113,7 +113,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash
# Some bash scripts needed throughout the layers
COPY --chmod=755 docker/*.sh /app/docker/
RUN pip install --no-cache-dir --upgrade uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Using uv as it's faster/simpler than pip
RUN uv venv /app/.venv
@@ -202,6 +202,8 @@ RUN mkdir -p /app/data && chown -R superset:superset /app/data
# Copy compiled things from previous stages
COPY --from=superset-node /app/superset/static/assets superset/static/assets
# Copy service.worker.js optionall as it doesn't exist when DEV_MODE=true
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
# TODO, when the next version comes out, use --exclude superset/translations
COPY superset superset
-540
View File
@@ -1,540 +0,0 @@
# Semantic Layer MCP — Research
**Story**: SC-98803 — Semantic Layer & MCP
**Branch**: `research-semantic-layer-mcp`
**Date**: 2026-05-20
---
## Story Summary
Story #98803 is titled "Semantic Layer & MCP" with no written description or acceptance criteria. Two
comments clarify intent:
1. "Probably worth meeting with Beto to start discussing what Semantic Layer work means for MCP"
2. "not putting any more stories in mcp v1 and rather triaging future work in the Preset MCP V2 epic"
This is a **research spike** to map out what "Semantic Layer MCP" would mean, what already exists, what
gaps remain, and what would go into MCP v2. No implementation is expected from this story.
---
## What Superset's Semantic Layer Is
Superset has **two distinct semantic layer concepts** that must be treated separately:
### 1. Built-in Dataset Semantic Layer (SqlaTable)
Every Superset dataset (`SqlaTable`) carries a lightweight semantic layer composed of:
| Concept | Model | Storage |
|---|---|---|
| **Saved metrics** | `SqlMetric` | `sql_metrics` table; `expression` column holds SQL (e.g. `COUNT(*)`, `SUM(revenue)`) |
| **Calculated columns** | `TableColumn.expression` | `table_columns` table; non-null `expression` = virtual column |
| **Dimensions** | `TableColumn` with `groupby=True` | Physical or calculated columns marked as groupable |
| **Filters** | `TableColumn` with `filterable=True` | Drives filter dropdowns |
| **Time grains** | `SqlaTable.main_dttm_col` | Primary datetime column for temporal slicing |
| **RLS** | `RowLevelSecurityFilter` | Per-user WHERE clause predicates |
At query time, `TableColumn.get_sqla_col()` either wraps the physical column (`column(name)`) or inlines
the SQL expression as `literal_column(expression)`. Jinja2 templates are processed before injection.
Saved metrics follow the same pattern via `SqlMetric.get_sqla_col()`.
Physical vs. virtual datasets:
- **Physical** — `sql=NULL`; metadata discovered from DB schema
- **Virtual** — `sql=<SELECT ...>`; metadata discovered by running the SQL; `is_sqllab_view=True`
### 2. External Semantic Layer (SemanticLayer / SemanticView)
A newer plugin-based system for connecting to external semantic catalogs:
- `SemanticLayer` model (`superset/semantic_layers/models.py`) — a connection record (type, encrypted config)
- `SemanticView` model — a queryable view within a layer (maps to a dbt model, a Cube cube, etc.)
- `superset/semantic_layers/registry.py` — plugin registry mapping `type → implementation class`
- `superset/semantic_layers/mapper.py` — translates Superset's `QueryObject``SemanticQuery` for
dispatch to the implementation; handles metrics, dimensions, filters, time grains, and time comparison
- REST API: `GET/POST /api/v1/semantic_layer/`, `GET/POST /api/v1/semantic_layer/{id}/semantic_view/`
- Query path uses `datasource_type="semantic_view"` (distinct from `"table"` for SqlaTable)
The registry is currently empty in OSS (`registry: dict[str, type[SemanticLayer]] = {}`); implementations
are contributed via the extension/plugin mechanism referenced by the TODO in `models.py`.
---
## Existing MCP Tool Surface
The MCP service (`superset/mcp_service/`) already covers Superset's built-in semantic layer well:
| Tool | Tag | What it does |
|---|---|---|
| `list_datasets` | discovery | List/search datasets by name, schema, database |
| `get_dataset_info` | discovery | Full dataset metadata: **columns** (dimensions) + **metrics** (saved metrics) |
| `query_dataset` | data | Query using metric names + column names + filters + time range — **no SQL needed** |
| `create_virtual_dataset` | mutate | Save a SQL SELECT as a named virtual dataset |
| `execute_sql` | data | Raw SQL against any DB (bypasses semantic layer entirely) |
`query_dataset` is the core semantic layer MCP tool. It:
1. Resolves dataset by ID or UUID
2. Validates requested metric/column names against the dataset's saved metrics and columns (with typo
suggestions via `difflib.get_close_matches`)
3. Builds a `QueryContext` with `datasource_type="table"` and dispatches via `ChartDataCommand`
4. Returns tabular rows, column metadata, cache status, and applied filters
**What is NOT covered:**
- No MCP tools for `SemanticLayer` or `SemanticView` models (external semantic layers)
- No cross-dataset metric/dimension discovery (no `list_metrics`, `list_dimensions` tools)
- No tool for querying external semantic layers via the `datasource_type="semantic_view"` path
---
## Proposed MCP Tools
The following analysis groups proposed tools by priority tier.
### Tier 1 — External Semantic Layer Discovery (highest value, unblocked)
These tools mirror what `list_datasets` / `get_dataset_info` do for SqlaTable but for the external
semantic layer model.
---
**`list_semantic_layers`**
- **Description**: List available external semantic layer connections (dbt, Cube, etc.)
- **Inputs**: `page`, `page_size`, `search` (optional name filter)
- **Outputs**: `[{id, uuid, name, type, description, view_count}]`, pagination
- **Pattern**: `ModelListCore(SemanticLayerDAO, ...)`
- **Notes**: Requires `SemanticLayer` FAB permission; respects `semantic_layer_access` PVM.
---
**`get_semantic_layer_info`**
- **Description**: Get details of a semantic layer including its registered views
- **Inputs**: `identifier` (UUID)
- **Outputs**: `{uuid, name, type, description, views: [{id, uuid, name, description}]}`
- **Pattern**: `ModelGetInfoCore(SemanticLayerDAO, ...)`
- **Notes**: Views list drives the LLM to choose the right view before querying.
---
**`list_semantic_views`**
- **Description**: List views within a specific semantic layer
- **Inputs**: `semantic_layer_uuid`, `page`, `page_size`, `search`
- **Outputs**: `[{id, uuid, name, description, metric_count, dimension_count}]`
- **Pattern**: DAO query on `SemanticView` filtered by `semantic_layer_uuid`
- **Notes**: Separate from `get_semantic_layer_info` to support pagination on large layers.
---
### Tier 2 — External Semantic Layer Query
---
**`get_semantic_view_schema`**
- **Description**: Get available metrics and dimensions for a semantic view
- **Inputs**: `semantic_view_id` (int or UUID)
- **Outputs**: `{metrics: [{name, type, description, ...}], dimensions: [{name, type, is_dttm, ...}]}`
- **Notes**: Calls the layer's `implementation` to fetch live schema from the external system. This is
distinct from `get_dataset_info` because the metadata comes from the external catalog, not Superset's DB.
The `MetricMetadata` and `ColumnMetadata` dataclasses in `semantic_layers/models.py` define the shape.
---
**`query_semantic_view`**
- **Description**: Query an external semantic layer view by metric and dimension names
- **Inputs**: `semantic_view_id`, `metrics: list[str]`, `dimensions: list[str]`, `filters`, `time_range`,
`time_grain`, `row_limit`
- **Outputs**: Same shape as `query_dataset` (tabular rows + column metadata + performance)
- **Notes**: Uses the `datasource_type="semantic_view"` path through `QueryContextFactory`. The
`mapper.py` `get_results()` function already handles the translation. This is a peer to `query_dataset`,
not a replacement.
- **Key design question**: How to handle authentication to the external system (dbt Cloud token, Cube API
key, etc.) — stored in encrypted `SemanticLayer.configuration`, fetched at query time.
---
### Tier 3 — Cross-Dataset Discovery (nice-to-have, lower priority)
These are useful for LLM agents that need to find metrics without knowing which dataset to start from.
---
**`list_metrics`**
- **Description**: Search saved metrics across all accessible datasets
- **Inputs**: `search` (metric name / description), `dataset_id` (optional), `page`, `page_size`
- **Outputs**: `[{metric_name, expression, dataset_id, dataset_name, description, d3format}]`
- **Notes**: Direct DAO query on `SqlMetric` with RBAC filtering. High value for LLMs that start with
"find me a revenue metric" rather than "query dataset 42."
---
**`list_dimensions`**
- **Description**: Search groupable columns across accessible datasets
- **Inputs**: `search`, `dataset_id` (optional), `is_dttm` (optional), `page`, `page_size`
- **Outputs**: `[{column_name, type, is_dttm, dataset_id, dataset_name, description}]`
- **Notes**: Same pattern as `list_metrics` but on `TableColumn` with `groupby=True`.
Complementary to `list_metrics` for fully metric-driven workflows.
---
### Summary Table
| Tool | Tier | Effort | Depends on |
|---|---|---|---|
| `list_semantic_layers` | 1 | S | `SemanticLayerDAO` (exists) |
| `get_semantic_layer_info` | 1 | S | `SemanticLayerDAO` (exists) |
| `list_semantic_views` | 1 | S | `SemanticLayerDAO` (exists) |
| `get_semantic_view_schema` | 2 | M | External layer implementation |
| `query_semantic_view` | 2 | M | `mapper.py` (exists), `QueryContextFactory` |
| `list_metrics` | 3 | S | `SqlMetric` DAO query |
| `list_dimensions` | 3 | S | `TableColumn` DAO query |
Effort scale: S = 12 days, M = 35 days, L = 12 weeks, XL = 2+ weeks.
---
## Landscape Survey
### How Other Tools Do It
**Looker** (Google)
- Looker has a full Semantic Layer API: `/api/4.0/lookml_models/{name}/explores/{name}/fields` returns
dimensions, measures, and filters from LookML
- LookML is the semantic definition language; AI/LLM access is via Looker's Explore REST API
- Looker Studio integration reads the semantic layer directly
**Cube.dev**
- Cube ships an official MCP server (`@cubejs-backend/mcp`) with:
- `list_cubes` — returns all cubes with measure/dimension/segment definitions
- `query_cube` — runs a semantic query by cube+measures+dimensions+filters
- `get_cube_meta` — live schema for a specific cube
- This is the closest prior art to what Superset's Tier 1+2 tools would look like
**dbt Semantic Layer (MetricFlow)**
- dbt Cloud exposes metrics via JDBC/GraphQL Semantic Layer API
- MetricFlow's conceptual model: `Metric`, `Dimension`, `Entity`, `TimeDimension`
- LLM integrations (e.g., dbt Copilot) use `list_metrics`, `list_dimensions`, and a `query_metrics`
endpoint that returns results as Arrow IPC
**MetricFlow**
- Query API: `list_metrics()`, `list_dimensions()`, `query_metrics(metrics=[], groupBy=[])`
- Self-describing schema: each metric knows its required dimensions and time grains
**Key takeaway**: The industry pattern is consistent — a 3-tool surface: `list_metrics`, `list_dimensions`,
`query_metrics`. Superset's `get_dataset_info` + `query_dataset` already implement this pattern for the
built-in semantic layer. The gap is the external semantic layer equivalents and cross-dataset discovery.
### Prior Art in the Superset Community
- The `semantic_layers/` module (models, mapper, registry, API) was built ahead of any specific
implementation — it is the extension point for dbt, Cube, Snowflake Cortex, etc.
- No OSS implementations are registered; implementations come via the Preset plugin mechanism
- No Preset-specific semantic layer MCP tools exist in `superset-shell/preset/mcp/` today
---
## Implementation Approach
### Phase 1: External Semantic Layer Discovery (Tier 1)
The `SemanticLayerDAO` already exists and the REST API is live. Adding `list_semantic_layers`,
`get_semantic_layer_info`, and `list_semantic_views` follows the exact same pattern as the existing
dataset tools. Use `ModelListCore` and `ModelGetInfoCore` from `mcp_core.py`. This is straightforward
work (3 × S = ~34 days total) and unblocked.
Create `superset/mcp_service/semantic_layer/` module with:
- `schemas.py` — Pydantic schemas for SemanticLayer and SemanticView
- `tool/list_semantic_layers.py`
- `tool/get_semantic_layer_info.py`
- `tool/list_semantic_views.py`
- `tool/__init__.py`
Register all three in `app.py`.
### Phase 2: External Semantic Layer Query (Tier 2)
`get_semantic_view_schema` requires calling the external implementation's schema method, which may do
a network call. This needs timeout handling and graceful error messaging.
`query_semantic_view` is the most complex: it must go through the `mapper.py` + `QueryContextFactory`
path with `datasource_type="semantic_view"`. The query execution path exists but is untested from MCP.
Recommend writing this alongside integration tests against a mock external layer.
### Phase 3: Cross-Dataset Discovery (Tier 3)
`list_metrics` and `list_dimensions` are pure DAO queries on `SqlMetric` and `TableColumn`. They need
RBAC filtering (only return metrics/columns from datasets the user can access) which adds complexity.
The simplest safe approach is to call `DatasetDAO.find_all()` to get accessible datasets and then filter
metrics/columns in Python — this avoids writing a custom RBAC-aware JOIN.
### Key Design Decisions
1. **Unified `query` tool vs. split tools**: Should `query_dataset` be extended to detect
`semantic_view_id` inputs, or should `query_semantic_view` be a separate tool? Recommendation:
**separate tools** — the query paths are genuinely different (different datasource types, different
schema shapes) and separate tools are clearer for LLMs.
2. **Schema exposure**: Should `get_semantic_view_schema` return the full external schema or only the
subset Superset knows about? Recommendation: **live from external system** — this is the value prop.
3. **`list_metrics` RBAC**: Should it join dataset permissions in SQL or filter in Python? Recommendation:
**Python filtering via DatasetDAO** until proven to be a performance problem.
4. **Prompt updates**: `DEFAULT_INSTRUCTIONS` in `app.py` should be updated to mention the semantic layer
tools and when to prefer them over `execute_sql`.
---
## Questions for Beto — with Answers (2026-06-04)
1. **What does MCP v2 mean architecturally?** The story comment says these tools belong in "MCP v2
epic." Are there breaking changes (new tool naming, new auth model, new transport) that affect how
these tools should be designed?
> **💬 Beto:** No. It's just the second release of the MCP service — not a breaking change. Build
> these tools with the same conventions as v1.
2. **Which external semantic layer implementations exist at Preset?** The `registry` is empty in OSS.
What types are registered in `superset-shell`? Which implementations should `query_semantic_view`
be tested against first (dbt, Snowflake Cortex, Cube)?
> **💬 Beto:** Our first target is Snowflake, followed by MetricFlow, Cube, DJ. But it shouldn't
> matter — the interface is identical for all of them.
3. **Is `SemanticLayer.implementation` wiring complete?** There's a `TODO` comment in
`semantic_layers/models.py` referencing an `extension_manager` that isn't fully wired yet (currently
falls back directly to `registry[self.type]`). Is the plugin mechanism stable enough to build MCP
tools on top of?
> **💬 Beto:** It is. The TODO in the discovery mechanism is because we've been discussing changing
> how installed extensions are discovered in the future, but it won't affect any APIs — it's purely
> internal. Safe to build on top of today.
4. **RLS for external semantic layers**: SqlaTable RLS is handled by `get_sqla_row_level_filters()`.
External layers have their own permission models (dbt Cloud access, Cube tenant isolation). How should
MCP enforce per-user data access when querying external layers?
> **💬 Beto:** We don't currently support RLS in semantic layers/views, but will eventually. Don't
> worry about this now — when we add support it will be transparent to the MCP service.
5. **Should `list_metrics` / `list_dimensions` span built-in + external layers?** A unified
`list_metrics` spanning both SqlaTable saved metrics and external semantic layer metrics could be
very powerful but complex. Or should they be separate surfaces?
> **💬 Beto:** Yes — unified. Most users will have only a single semantic layer (the whole point is
> a single source of truth). Because of this, we should never force the user to choose a layer before
> listing metrics — most of the time there will be only one, and we should never prompt to choose from
> a single-element list. Even when there are multiple, start with global search.
6. **What is the intended LLM workflow for external semantic layers?** For built-in datasets the
workflow is: `list_datasets``get_dataset_info``query_dataset`. What's the analogous flow
for external layers?
> **💬 Beto:** The flow is `list_metrics` → metrics come with related dimensions already — then
> `get_table(metrics, dimensions, filters)`. Also worth exposing the `get_compatible_*()` methods
> from `superset-core/src/superset_core/semantic_layers/view.py`. When listing metrics, compatible
> dimensions should be part of the metric payload itself.
---
## Effort Estimate (original)
| Scope | Effort | Notes |
|---|---|---|
| `list_semantic_layers` | S (12 days) | Follows exact ModelListCore pattern |
| `get_semantic_layer_info` | S (12 days) | Follows exact ModelGetInfoCore pattern |
| `list_semantic_views` | S (1 day) | Subset of get_semantic_layer_info |
| `get_semantic_view_schema` | M (3 days) | Network call to external system + error handling |
| `query_semantic_view` | M (45 days) | New query path + integration tests |
| `list_metrics` | S (2 days) | DAO query + RBAC filter |
| `list_dimensions` | S (12 days) | Same pattern as list_metrics |
| Prompt/instructions updates | S (1 day) | Update DEFAULT_INSTRUCTIONS + tool docs |
| **Total Tier 1** | **~1 week** | |
| **Total Tier 1+2** | **~2.5 weeks** | Assuming external implementation is available |
| **Total Tier 1+2+3** | **~3.5 weeks** | |
---
## Final Architecture Proposal
*Synthesized from the research above and Beto's 2026-06-04 answers.*
### Core Insight: Flat Metric-First Workflow
Beto's answer to Q6 collapses the original three-tier hierarchy into a much flatter design. The intended
LLM workflow is:
```
list_metrics → get_table(metrics, dimensions, filters)
```
Not the hierarchical: `list_semantic_layers → list_semantic_views → get_semantic_view_schema → query_semantic_view`.
This means `list_metrics` is the **primary entry point** — not an administrative helper — and it must
work globally across both built-in datasets and external semantic layers without the user choosing a
layer first. Compatible dimensions travel with each metric in the response payload.
The abstract interface in `superset-core/src/superset_core/semantic_layers/view.py` is the contract
we're wrapping. Every proposed MCP tool maps to a method there:
| MCP Tool | `view.py` method | Notes |
|---|---|---|
| `list_metrics` | `get_metrics()` | + `SqlMetric` for built-in |
| `get_compatible_dimensions` | `get_compatible_dimensions(metrics, dims)` | Refinement during selection |
| `get_compatible_metrics` | `get_compatible_metrics(metrics, dims)` | Refinement during selection |
| `get_table` | `get_table(query)` | Unified query; replaces `query_dataset` + `query_semantic_view` |
| `get_dimension_values` | `get_values(dimension, filters)` | Distinct values for filter UI |
| `get_row_count` | `get_row_count(query)` | Preview row count before full fetch |
### Revised Tool Design
#### `list_metrics` ★ Primary tool
Search all accessible metrics globally — spanning both built-in (`SqlMetric`) and external semantic
layer metrics (via `SemanticView.get_metrics()`). No layer selection required.
- **Inputs:** `search` (optional text filter), `page`, `page_size`
- **Outputs:**
```json
[{
"name": "total_revenue",
"description": "...",
"source": {"type": "dataset" | "semantic_view", "id": "<uuid>", "name": "Sales"},
"compatible_dimensions": [
{"name": "region", "type": "VARCHAR", "is_dttm": false},
{"name": "order_date", "type": "TIMESTAMP", "is_dttm": true}
]
}]
```
- **RBAC:** Built-in metrics: filter by accessible datasets via `DatasetDAO`. External: filter by
accessible `SemanticLayer` connections.
- **Effort:** M (34 days) — unified across both sources is more complex than a single DAO query.
---
#### `get_table` ★ Unified query tool
Execute a semantic query against any metric source. Routes to the built-in path or the external
`SemanticView.get_table()` path based on where the metrics come from.
- **Inputs:** `metrics: list[str]`, `dimensions: list[str]`, `filters`, `time_range`, `time_grain`,
`row_limit`
- **Outputs:** Tabular rows + column metadata + performance info (same shape as existing `query_dataset`)
- **Routing logic:**
- All metrics from `SqlMetric` → existing `ChartDataCommand` path (`datasource_type="table"`)
- Metrics from `SemanticView` → `mapper.py` + `datasource_type="semantic_view"` path
- Mixed → error (metrics must come from a single source)
- **Notes:** This is the peer/replacement for `query_dataset` in semantic-layer-first workflows.
Existing `query_dataset` is unchanged — it remains the direct-dataset tool.
- **Effort:** M (45 days) — routing logic + integration tests against both paths.
---
#### `get_compatible_dimensions`
Refine dimension selection given already-chosen metrics and dimensions. Surfaces
`view.get_compatible_dimensions()`.
- **Inputs:** `metric_names: list[str]`, `selected_dimensions: list[str]` (already picked)
- **Outputs:** `[{name, type, is_dttm, description}]` — dimensions compatible with the current selection
- **Use case:** LLM is helping user build a query step-by-step; user has picked 2 metrics and now asks
"what dimensions can I group by?"
- **Effort:** S (12 days)
---
#### `get_compatible_metrics`
Mirror of `get_compatible_dimensions` — refine metric selection given selected dimensions.
- **Inputs:** `selected_metrics: list[str]`, `dimension_names: list[str]`
- **Outputs:** `[{name, description, source}]` — metrics compatible with the selected dimensions
- **Effort:** S (1 day)
---
#### `get_dimension_values`
Fetch distinct values for a dimension (for filter UI or LLM-driven filtering). Surfaces
`view.get_values()`.
- **Inputs:** `dimension_name: str`, `source_id` (dataset UUID or semantic_view UUID), `filters`
(optional pre-filters)
- **Outputs:** `[{value, label}]`
- **Effort:** S (12 days)
---
#### `get_row_count`
Count rows a query would return before executing it — useful for previews and safety checks. Surfaces
`view.get_row_count()`.
- **Inputs:** `metrics: list[str]`, `dimensions: list[str]`, `filters`, `time_range`
- **Outputs:** `{row_count: int}`
- **Effort:** S (1 day)
---
#### Admin Discovery Tools (lower priority)
These are useful for admin/setup workflows but are NOT part of the primary LLM query flow:
| Tool | Effort | Purpose |
|---|---|---|
| `list_semantic_layers` | S (12 days) | List configured external connections |
| `get_semantic_layer_info` | S (12 days) | Details of a connection + its views |
| `list_semantic_views` | S (1 day) | Paginated views within a layer |
---
### What Changed from the Original Proposal
| Original | Revised | Reason |
|---|---|---|
| Tier 3 (`list_metrics`) = lowest priority | `list_metrics` = **highest priority** | Beto: it's the primary entry point |
| Separate `query_dataset` + `query_semantic_view` | Unified `get_table` | Beto: single query tool for the semantic workflow |
| `get_semantic_view_schema` for schema discovery | `list_metrics` includes compatible dims inline | Beto: schema travels with metrics |
| Layer selection required before listing metrics | Global search, no layer selection | Beto: single source of truth, never prompt to choose |
| `get_compatible_*()` not in scope | Now explicit MCP tools | Beto: expose these directly |
### Implementation Phases
**Phase 1 — Core semantic workflow (~2 weeks, mostly unblocked)**
All built-in layer support; external layer support added in Phase 2.
1. `list_metrics` (built-in SqlMetric + stub for external)
2. `get_table` (built-in path first; `datasource_type="table"`)
3. `get_compatible_dimensions` / `get_compatible_metrics`
4. Update `DEFAULT_INSTRUCTIONS` in `app.py`
**Phase 2 — External layer support (~1.5 weeks, needs Snowflake plugin)**
1. Extend `list_metrics` to query external `SemanticView.get_metrics()`
2. Extend `get_table` to route to `datasource_type="semantic_view"` path
3. `get_dimension_values` (via `view.get_values()`)
4. `get_row_count` (via `view.get_row_count()`)
**Phase 3 — Admin tools (~0.5 weeks, independent)**
1. `list_semantic_layers` / `get_semantic_layer_info` / `list_semantic_views`
### Revised Effort Summary
| Tool | Phase | Effort | Blocked? |
|---|---|---|---|
| `list_metrics` (built-in) | 1 | M (3 days) | No |
| `get_table` (built-in path) | 1 | M (34 days) | No |
| `get_compatible_dimensions` | 1 | S (12 days) | No |
| `get_compatible_metrics` | 1 | S (1 day) | No |
| `DEFAULT_INSTRUCTIONS` update | 1 | S (1 day) | No |
| `list_metrics` (external) | 2 | M (2 days) | Yes — needs Snowflake plugin |
| `get_table` (external path) | 2 | M (23 days) | Yes — needs Snowflake plugin |
| `get_dimension_values` | 2 | S (12 days) | Yes — needs Snowflake plugin |
| `get_row_count` | 2 | S (1 day) | Yes — needs Snowflake plugin |
| `list_semantic_layers` | 3 | S (12 days) | No |
| `get_semantic_layer_info` | 3 | S (12 days) | No |
| `list_semantic_views` | 3 | S (1 day) | No |
| **Total Phase 1** | | **~2 weeks** | |
| **Total Phase 1+2** | | **~3.5 weeks** | |
| **Total Phase 1+2+3** | | **~4 weeks** | |
@@ -88,7 +88,6 @@ using our `docker compose` constructs to support production-type use-cases. For
environments, we recommend using [minikube](https://minikube.sigs.k8s.io/docs/start/) along
our [installing on k8s](https://superset.apache.org/docs/installation/running-on-kubernetes)
documentation.
configured to be secure.
:::
### Supported environment variables
@@ -335,6 +335,92 @@ npm run build-translation
pybabel compile -d superset/translations
```
### Backfilling missing translations with AI
For languages with many untranslated strings, the repo includes a script that
uses Claude AI to generate draft translations for any missing entries. All
AI-generated strings are marked `#, fuzzy` and tagged with an attribution
comment so that human reviewers know they need to be checked before merging.
#### Prerequisites
```bash
pip install -r superset/translations/requirements.txt
```
Claude Code must be installed and authenticated (`claude --version` should
work). The script calls `claude -p` internally — no separate API key is needed.
#### Step 1 — Build the translation index
The index captures every already-translated string in every language and
serves as cross-language context for the AI. Rebuild it whenever `.po` files
change significantly:
```bash
python scripts/translations/build_translation_index.py
# Writes: superset/translations/translation_index.json
```
#### Step 2 — Preview with a dry run
Check what would be translated without writing anything:
```bash
python scripts/translations/backfill_po.py --lang fr --limit 20 --dry-run
```
Output shows each string, its translation, and a context tag:
- No tag — 3+ reference languages available (high confidence)
- `[ctx:N]` — only N other languages have this string (lower confidence)
- `[ctx:0]` — no other language has this string yet; English alone used
#### Step 3 — Run the backfill
```bash
python scripts/translations/backfill_po.py --lang fr
```
Options:
| Flag | Default | Description |
|------|---------|-------------|
| `--lang LANG` | required | ISO language code (`fr`, `de`, `ja`, …) |
| `--batch-size N` | 50 | Strings per Claude request |
| `--limit N` | unlimited | Stop after N entries |
| `--min-context N` | 0 | Skip entries with fewer than N reference translations |
| `--model MODEL` | `claude-sonnet-4-6` | Claude model to use |
| `--dry-run` | off | Print without writing |
| `--no-fuzzy` | off | Don't mark entries as fuzzy |
Use `--min-context 2` to skip strings that have fewer than 2 reference
translations in other languages. Those strings are more likely to be ambiguous
(short labels, UI fragments) where the correct meaning can't be inferred
without additional context.
#### Step 4 — Review and commit
Open the target `.po` file and search for `fuzzy`. For each generated entry:
1. Verify the translation is correct for the UI context.
2. Remove the `# Machine-translated via backfill_po.py` comment and the
`#, fuzzy` flag line once you are satisfied.
3. If the translation is wrong, correct the `msgstr` before removing the flag.
4. Commit the `.po` file — do **not** commit `translation_index.json` (it is
gitignored and regenerated locally).
#### Running via npm
From `superset-frontend/`:
```bash
# Rebuild index
npm run translations:build-index
# Backfill (pass arguments after --)
npm run translations:backfill -- --lang fr --dry-run
```
## Linting
### Python
+8 -4
View File
@@ -70,9 +70,9 @@
"@storybook/preview-api": "^8.6.18",
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.33",
"@swc/core": "^1.15.40",
"antd": "^6.4.3",
"baseline-browser-mapping": "^2.10.31",
"baseline-browser-mapping": "^2.10.32",
"caniuse-lite": "^1.0.30001793",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
@@ -110,7 +110,7 @@
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.4",
"webpack": "^5.107.0"
"webpack": "^5.107.1"
},
"browserslist": {
"production": [
@@ -128,7 +128,11 @@
"react-redux": "^9.2.0",
"@reduxjs/toolkit": "^2.5.0",
"baseline-browser-mapping": "^2.9.19",
"swagger-client": "3.37.3"
"swagger-client": "3.37.3",
"lodash": "4.18.1",
"lodash-es": "4.18.1",
"yaml": "1.10.3",
"uuid": "11.1.1"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}
+86 -108
View File
@@ -4033,86 +4033,86 @@
dependencies:
apg-lite "^1.0.4"
"@swc/core-darwin-arm64@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz#d84134fb80417d41128739f0b9014542e3ed9dd3"
integrity sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==
"@swc/core-darwin-arm64@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz#b05d715b04c4fd47baf59288233da85a683cc0bc"
integrity sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==
"@swc/core-darwin-x64@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz#0badb9834071f1c6005986571d4a96359c1d7cd0"
integrity sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==
"@swc/core-darwin-x64@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz#3180daef5c1e47b435f8edd084509e0a5c0d883b"
integrity sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==
"@swc/core-linux-arm-gnueabihf@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz#b7577a825b59d98b6a9a5c991d842046efe1c34a"
integrity sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==
"@swc/core-linux-arm-gnueabihf@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz#18fcd3c70e48fdfae07c9f18751b1409ce1e5e84"
integrity sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==
"@swc/core-linux-arm64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz#304c48321494a18c67b2913c273b08674ee70d8c"
integrity sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==
"@swc/core-linux-arm64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz#26304933922f2a8e3194770e404403fc25a19c89"
integrity sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==
"@swc/core-linux-arm64-musl@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz#d116cbc04ccb4f4ee810da6bca79d4423605dbcd"
integrity sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==
"@swc/core-linux-arm64-musl@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz#3402dfba04ba7b8ea81f243e2f8fa2c336b54d03"
integrity sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==
"@swc/core-linux-ppc64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz#f5354dba36db9414305bab344c817d57b8b457c2"
integrity sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==
"@swc/core-linux-ppc64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz#b3df9065cad352328c1eeef08a28fc9fe98785aa"
integrity sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==
"@swc/core-linux-s390x-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz#016df9f4c9d7fd65b85ca9c558c5aec341f06da0"
integrity sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==
"@swc/core-linux-s390x-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz#58e5b601f641dde81b30626ef66a668701ec918f"
integrity sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==
"@swc/core-linux-x64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz#49f36558ede072e71999aa37f123367daed2a662"
integrity sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==
"@swc/core-linux-x64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz#cf057dce0c148c53f2d30152baaf60ea29e5d59c"
integrity sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==
"@swc/core-linux-x64-musl@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz#b096665f5cfeee2612325f301da5c1590b10d8f3"
integrity sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==
"@swc/core-linux-x64-musl@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz#21fb1a4d0193e9bbcd1469ecd36166d2e96e4006"
integrity sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==
"@swc/core-win32-arm64-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz#f3101263a0dbaa173ec47638c9719d0b89838bd2"
integrity sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==
"@swc/core-win32-arm64-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz#1dba23b2b0db86b3d6d65da2abd627cc607a1fbc"
integrity sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==
"@swc/core-win32-ia32-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz#eb981ef5613d42c9220559bdb0c8bc58cf6c3eb9"
integrity sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==
"@swc/core-win32-ia32-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz#b2da1e33165d469467b1046a2189db468da488eb"
integrity sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==
"@swc/core-win32-x64-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz#a2fed9956933027ceb368857bac4bb4ee203d47c"
integrity sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==
"@swc/core-win32-x64-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz#3563f7e8ce8708f5fda43eb8e0956ef11e0da320"
integrity sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==
"@swc/core@^1.15.33", "@swc/core@^1.7.39":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.33.tgz#2a6571c8aca961925f14beae52b3f43c18370fc6"
integrity sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==
"@swc/core@^1.15.40", "@swc/core@^1.7.39":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.40.tgz#941c949aa88c0d8d291f102f519f3c2c77701b90"
integrity sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.26"
optionalDependencies:
"@swc/core-darwin-arm64" "1.15.33"
"@swc/core-darwin-x64" "1.15.33"
"@swc/core-linux-arm-gnueabihf" "1.15.33"
"@swc/core-linux-arm64-gnu" "1.15.33"
"@swc/core-linux-arm64-musl" "1.15.33"
"@swc/core-linux-ppc64-gnu" "1.15.33"
"@swc/core-linux-s390x-gnu" "1.15.33"
"@swc/core-linux-x64-gnu" "1.15.33"
"@swc/core-linux-x64-musl" "1.15.33"
"@swc/core-win32-arm64-msvc" "1.15.33"
"@swc/core-win32-ia32-msvc" "1.15.33"
"@swc/core-win32-x64-msvc" "1.15.33"
"@swc/core-darwin-arm64" "1.15.40"
"@swc/core-darwin-x64" "1.15.40"
"@swc/core-linux-arm-gnueabihf" "1.15.40"
"@swc/core-linux-arm64-gnu" "1.15.40"
"@swc/core-linux-arm64-musl" "1.15.40"
"@swc/core-linux-ppc64-gnu" "1.15.40"
"@swc/core-linux-s390x-gnu" "1.15.40"
"@swc/core-linux-x64-gnu" "1.15.40"
"@swc/core-linux-x64-musl" "1.15.40"
"@swc/core-win32-arm64-msvc" "1.15.40"
"@swc/core-win32-ia32-msvc" "1.15.40"
"@swc/core-win32-x64-msvc" "1.15.40"
"@swc/counter@^0.1.3":
version "0.1.3"
@@ -5568,10 +5568,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.31"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
baseline-browser-mapping@^2.10.32, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.32"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64"
integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==
batch@0.6.1:
version "0.6.1"
@@ -9676,10 +9676,10 @@ locate-path@^7.1.0:
dependencies:
p-locate "^6.0.0"
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash-es@4.18.1, lodash-es@^4.17.21:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
lodash.debounce@^4, lodash.debounce@^4.0.8:
version "4.0.8"
@@ -9701,12 +9701,7 @@ lodash.uniq@^4.5.0:
resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
lodash@4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
lodash@4.17.21, lodash@4.18.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
@@ -12270,14 +12265,7 @@ pvutils@^1.1.3, pvutils@^1.1.5:
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.5.tgz#84b0dea4a5d670249aa9800511804ee0b7c2809c"
integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==
qs@^6.12.3:
version "6.14.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c"
integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==
dependencies:
side-channel "^1.1.0"
qs@~6.15.1:
qs@^6.12.3, qs@~6.15.1:
version "6.15.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9"
integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
@@ -14733,15 +14721,10 @@ utils-merge@1.0.1:
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@8.3.2, uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
"uuid@^11.1.0 || ^12 || ^13 || ^14.0.0":
version "14.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d"
integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==
uuid@11.1.1, uuid@8.3.2, "uuid@^11.1.0 || ^12 || ^13 || ^14.0.0", uuid@^8.3.2:
version "11.1.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad"
integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==
uvu@^0.5.0:
version "0.5.6"
@@ -14964,10 +14947,10 @@ webpack-virtual-modules@^0.6.2:
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
webpack@^5.107.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.107.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.0.tgz#9e0d8d8baf24e76f058103f4f06ac6bb528b645a"
integrity sha512-PSxeHk/dmLYZlnTU+vL1Gej6Evg5RNtl3flhxBresfznFnzxinHMzHKloHnywM/3ouQv7/AlZCswWDIkNSggUA==
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
version "5.107.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
dependencies:
"@types/estree" "^1.0.8"
"@types/json-schema" "^7.0.15"
@@ -15146,9 +15129,9 @@ ws@^7.3.1:
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.18.0, ws@^8.2.3:
version "8.18.3"
resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
version "8.20.1"
resolved "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz"
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
wsl-utils@^0.1.0:
version "0.1.0"
@@ -15216,12 +15199,7 @@ yaml-ast-parser@0.0.43:
resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz"
integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==
yaml@1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^1.10.0:
yaml@1.10.2, yaml@1.10.3, yaml@^1.10.0:
version "1.10.3"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
+8 -7
View File
@@ -39,7 +39,7 @@ dependencies = [
"apache-superset-core",
"backoff>=1.8.0",
"celery>=5.3.6, <6.0.0",
"click>=8.0.3",
"click>=8.4.0",
"click-option-group",
"colorama",
"flask-cors>=6.0.0, <7.0",
@@ -58,7 +58,7 @@ dependencies = [
"flask-wtf>=1.1.0, <2.0",
"geopy",
"greenlet>=3.0.3, <=3.5.0",
"gunicorn>=22.0.0; sys_platform != 'win32'",
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
"holidays>=0.45, <1",
@@ -66,7 +66,7 @@ dependencies = [
"isodate",
"jsonpath-ng>=1.6.1, <2",
"Mako>=1.2.2",
"markdown>=3.0",
"markdown>=3.10.2",
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
"marshmallow>=3.0, <4",
"marshmallow-union>=0.1",
@@ -101,9 +101,9 @@ dependencies = [
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=28.10.0, <29",
"sqlglot>=30.8.0, <31",
# newer pandas needs 0.9+
"tabulate>=0.9.0, <1.0",
"tabulate>=0.10.0, <1.0",
"typing-extensions>=4, <5",
"waitress; sys_platform == 'win32'",
"watchdog>=6.0.0",
@@ -137,9 +137,9 @@ databricks = [
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.4, <2"]
drill = ["sqlalchemy-drill>=1.1.10, <2"]
druid = ["pydruid>=0.6.5,<0.7"]
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
duckdb = ["duckdb>=1.5.2,<2", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
@@ -220,6 +220,7 @@ development = [
"openapi-spec-validator",
"parameterized",
"pip",
"polib", # used by scripts/translations/ and their unit tests
"pre-commit",
"progress>=1.5,<2",
"psutil",
+7 -7
View File
@@ -60,7 +60,7 @@ cffi==2.0.0
# pynacl
charset-normalizer==3.4.2
# via requests
click==8.2.1
click==8.4.1
# via
# apache-superset (pyproject.toml)
# celery
@@ -166,7 +166,7 @@ greenlet==3.1.1
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
gunicorn==25.3.0
# via apache-superset (pyproject.toml)
h11==0.16.0
# via wsproto
@@ -208,12 +208,12 @@ kombu==5.5.3
# via celery
limits==5.1.0
# via flask-limiter
mako==1.3.11
mako==1.3.12
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
# alembic
markdown==3.8.1
markdown==3.10.2
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
@@ -415,13 +415,13 @@ sqlalchemy-utils==0.42.0
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==28.10.0
sqlglot==30.8.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.9.0
tabulate==0.10.0
# via apache-superset (pyproject.toml)
trio==0.30.0
# via
@@ -451,7 +451,7 @@ tzdata==2025.2
# pandas
url-normalize==2.2.1
# via requests-cache
urllib3==2.6.3
urllib3==2.7.0
# via
# -r requirements/base.in
# requests
+14 -11
View File
@@ -130,7 +130,7 @@ charset-normalizer==3.4.2
# via
# -c requirements/base-constraint.txt
# requests
click==8.2.1
click==8.4.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -219,7 +219,7 @@ docstring-parser==0.17.0
# via cyclopts
docutils==0.22.2
# via rich-rst
duckdb==1.4.2
duckdb==1.5.3
# via
# apache-superset
# duckdb-engine
@@ -346,6 +346,7 @@ google-auth==2.43.0
# google-api-core
# google-auth-oauthlib
# google-cloud-bigquery
# google-cloud-bigquery-storage
# google-cloud-core
# pandas-gbq
# pydata-google-auth
@@ -360,7 +361,7 @@ google-cloud-bigquery==3.27.0
# apache-superset
# pandas-gbq
# sqlalchemy-bigquery
google-cloud-bigquery-storage==2.19.1
google-cloud-bigquery-storage==2.26.0
# via pandas-gbq
google-cloud-core==2.4.1
# via google-cloud-bigquery
@@ -388,7 +389,7 @@ grpcio==1.71.0
# grpcio-status
grpcio-status==1.60.1
# via google-api-core
gunicorn==23.0.0
gunicorn==25.3.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -506,12 +507,12 @@ limits==5.1.0
# via
# -c requirements/base-constraint.txt
# flask-limiter
mako==1.3.11
mako==1.3.12
# via
# -c requirements/base-constraint.txt
# alembic
# apache-superset
markdown==3.8.1
markdown==3.10.2
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -677,6 +678,8 @@ ply==3.11
# via
# -c requirements/base-constraint.txt
# jsonpath-ng
polib==1.2.0
# via apache-superset
polyline==2.0.2
# via
# -c requirements/base-constraint.txt
@@ -699,7 +702,7 @@ proto-plus==1.25.0
# via
# google-api-core
# google-cloud-bigquery-storage
protobuf==4.25.8
protobuf==5.29.6
# via
# google-api-core
# google-cloud-bigquery-storage
@@ -837,7 +840,7 @@ python-dotenv==1.2.2
# pydantic-settings
python-ldap==3.4.4
# via apache-superset
python-multipart==0.0.20
python-multipart==0.0.29
# via mcp
pytz==2025.2
# via
@@ -985,7 +988,7 @@ sqlalchemy-utils==0.42.0
# apache-superset
# apache-superset-core
# flask-appbuilder
sqlglot==28.10.0
sqlglot==30.8.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -1004,7 +1007,7 @@ statsd==4.0.1
# via apache-superset
syntaqlite==0.1.0
# via apache-superset
tabulate==0.9.0
tabulate==0.10.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -1069,7 +1072,7 @@ url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
# requests-cache
urllib3==2.6.3
urllib3==2.7.0
# via
# -c requirements/base-constraint.txt
# botocore
+3 -1
View File
@@ -31,7 +31,9 @@ PATTERNS = {
r"^superset/",
r"^scripts/",
r"^setup\.py",
r"^pyproject\.toml$",
r"^requirements/.+\.txt",
r"^pyproject\.toml",
r"^.pylintrc",
],
"frontend": [
@@ -155,7 +157,7 @@ def main(event_type: str, sha: str, repo: str) -> None:
def get_git_sha() -> str:
return os.getenv("GITHUB_SHA") or subprocess.check_output( # noqa: S603
["git", "rev-parse", "HEAD"] # noqa: S607
["git", "rev-parse", "HEAD"] # noqa: S603, S607
).strip().decode("utf-8")
+1 -1
View File
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
echo "$output" >&2
exit 1
}
[ -n "$output" ] && echo "$output"
if [ -n "$output" ]; then echo "$output"; fi
else
echo "No JavaScript/TypeScript files to lint"
fi
+653
View File
@@ -0,0 +1,653 @@
# 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.
"""Backfill missing translations in a .po file using Claude AI.
For each untranslated (empty msgstr) entry in the target language, the script
sends the English source string along with all available translations in other
languages to Claude as context, then writes the AI-generated translation back
into the .po file marked as #, fuzzy for human review.
Usage:
# Build the translation index first (one-time or when .po files change)
python scripts/translations/build_translation_index.py
# Backfill French translations
python scripts/translations/backfill_po.py --lang fr
# Dry run (print what would be translated without writing)
python scripts/translations/backfill_po.py --lang de --dry-run
# Limit to 100 entries and use a specific model
python scripts/translations/backfill_po.py --lang es --limit 100 \
--model claude-opus-4-6
Options:
--lang LANG ISO language code to backfill (required)
--batch-size N Number of strings per Claude request (default: 50)
--limit N Stop after translating N entries (default: unlimited)
--min-context N Skip entries with fewer than N existing translations across
reference languages (default: 0 translate everything)
--model MODEL Claude model ID (default: claude-sonnet-4-6)
--index PATH Path to translation_index.json (default: auto-detect)
--dry-run Print translations without writing to .po file
--no-fuzzy Do not mark generated translations as fuzzy (default: mark fuzzy)
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
try:
import polib # type: ignore[import-untyped]
except ImportError:
print("polib is required. Run: pip install polib", file=sys.stderr)
sys.exit(1)
TRANSLATIONS_DIR = Path(__file__).parent.parent.parent / "superset" / "translations"
DEFAULT_INDEX = TRANSLATIONS_DIR / "translation_index.json"
DEFAULT_MODEL = "claude-sonnet-4-6"
DEFAULT_BATCH_SIZE = 50
# Language names for the prompt, keyed by ISO code
LANGUAGE_NAMES: dict[str, str] = {
"ar": "Arabic",
"ca": "Catalan",
"de": "German",
"es": "Spanish",
"fa": "Persian (Farsi)",
"fr": "French",
"it": "Italian",
"ja": "Japanese",
"ko": "Korean",
"mi": "Māori",
"nl": "Dutch",
"pl": "Polish",
"pt": "Portuguese",
"pt_BR": "Brazilian Portuguese",
"ru": "Russian",
"sk": "Slovak",
"sl": "Slovenian",
"tr": "Turkish",
"uk": "Ukrainian",
"zh": "Chinese (Simplified)",
"zh_TW": "Chinese (Traditional)",
}
def _lang_name(code: str) -> str:
"""Return a human-readable language name for an ISO language code."""
return LANGUAGE_NAMES.get(code, code)
def _plural_key(msgid: str, msgid_plural: str) -> str:
"""Build the translation index key used for pluralized entries."""
return f"{msgid}\x00{msgid_plural}"
def _is_missing(entry: polib.POEntry) -> bool:
"""Return True for entries that need a translation."""
if entry.obsolete:
return False
if entry.msgid_plural:
return not any(v for v in entry.msgstr_plural.values())
return not entry.msgstr
def _context_langs(
item: dict[str, Any], index: dict[str, Any], target_lang: str
) -> list[str]:
"""Return sorted list of language codes that have translations for this entry."""
key = item["index_key"]
if key not in index:
return []
return sorted(
lang for lang, val in index[key].items() if lang != target_lang and val
)
def _context_count(
item: dict[str, Any], index: dict[str, Any], target_lang: str
) -> int:
"""Return the number of other-language translations available for this entry."""
return len(_context_langs(item, index, target_lang))
def _render_item(
i: int,
item: dict[str, Any],
index: dict[str, Any],
target_lang: str,
reference_langs_sorted: list[str],
) -> list[str]:
"""Render one batch entry as prompt lines."""
lines: list[str] = []
ctx = _context_count(item, index, target_lang)
if ctx == 0:
lines.append(
f"--- [{i}] (no reference translations — translate conservatively) ---"
)
else:
plural = "s" if ctx != 1 else ""
lines.append(f"--- [{i}] ({ctx} reference translation{plural}) ---")
lines.append(f"English: {json.dumps(item['msgid'], ensure_ascii=False)}")
if item.get("msgid_plural"):
plural_json = json.dumps(item["msgid_plural"], ensure_ascii=False)
lines.append(f"English plural: {plural_json}")
key = item["index_key"]
if key in index and reference_langs_sorted:
for lang in reference_langs_sorted:
val = index[key].get(lang)
if val is None:
continue
if isinstance(val, dict):
forms = "; ".join(
f"[{k}] {json.dumps(v, ensure_ascii=False)}" for k, v in val.items()
)
lines.append(f"{_lang_name(lang)}: {forms}")
else:
lines.append(
f"{_lang_name(lang)}: {json.dumps(val, ensure_ascii=False)}"
)
lines.append("")
return lines
def build_prompt(
target_lang: str,
batch: list[dict[str, Any]],
index: dict[str, Any],
) -> str:
"""Build the Claude prompt for a batch of entries."""
lang_name = _lang_name(target_lang)
# Collect which other languages actually have translations for this batch
reference_langs: set[str] = set()
for item in batch:
key = item["index_key"]
if key in index:
reference_langs.update(
lang for lang, val in index[key].items() if lang != target_lang and val
)
reference_langs_sorted = sorted(reference_langs)
lines: list[str] = [
"You are a professional translator specializing in software UI strings.",
f"Translate the following English strings into {lang_name} ({target_lang}).",
"",
"Rules:",
"- Preserve all format placeholders exactly: %(name)s, {name}, %s, %d, etc.",
"- Preserve HTML tags if present.",
"- Keep the same tone and register as the reference translations.",
"- For plural forms, provide translations for all plural forms"
" required by the language.",
"- Return ONLY a JSON object mapping each numeric index (as a string)"
" to its translation.",
"- Do not add any explanation, preamble, or markdown fences.",
"",
"Important: Many strings are short fragments or single words that are"
" ambiguous in English (e.g. 'Scale' could mean a measurement scale,"
" to scale an image, or fish scales). Use the translations in other"
" languages as your primary signal for which meaning is intended —"
" they collectively disambiguate the intended sense. When no"
" other-language translations are available for an entry, translate"
" conservatively based on the most common meaning in a data"
" visualization UI context.",
"",
]
if reference_langs_sorted:
lines.append(
f"Reference translations are provided per string where available "
f"({', '.join(_lang_name(lc) for lc in reference_langs_sorted)})."
)
lines.append("")
lines.append("Strings to translate:")
lines.append("")
for i, item in enumerate(batch):
lines.extend(_render_item(i, item, index, target_lang, reference_langs_sorted))
# Add guidance on plural form counts per language whenever ANY entry in
# the batch is plural — batches mix singular and plural in .po order, so
# gating on the first entry would silently drop the guidance whenever
# the plural entries happen to land after a singular one.
if any(item.get("msgid_plural") for item in batch):
lines.append(
"Note: provide ALL plural forms required by the target language "
"(e.g. French needs 2, Russian needs 3, Arabic needs 6)."
)
lines.append("")
lines.append(
'Expected output format: {"0": "<translation>", "1": "<translation>", ...}'
)
lines.append("(keys are the numeric indices of the strings above)")
return "\n".join(lines)
def parse_response(text: str, batch_size: int) -> dict[int, str]:
"""Parse the JSON object from Claude's response."""
# Strip any accidental markdown fences
text = re.sub(r"^```[^\n]*\n", "", text.strip())
text = re.sub(r"\n```$", "", text)
try:
raw = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(
f"Could not parse response as JSON: {exc}\n\nResponse:\n{text}"
) from exc
# _process_batches only catches ValueError/RuntimeError, so a non-object
# response (list, scalar, null) must surface as ValueError rather than
# bubbling up an AttributeError from .items() and aborting the whole run.
if not isinstance(raw, dict):
raise ValueError(
f"Expected a JSON object mapping indices to translations, "
f"got {type(raw).__name__}.\n\nResponse:\n{text}"
)
# Preserve dict/list values as JSON strings so plural responses (where
# v is a dict of plural forms) can be re-parsed downstream by
# _apply_translation's json.loads. str(v) on a dict produces Python
# repr ({'0': 'x'}) which is not valid JSON.
return {
int(k): (
json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else str(v)
)
for k, v in raw.items()
if str(k).isdigit()
}
def translate_batch(
model: str,
target_lang: str,
batch: list[dict[str, Any]],
index: dict[str, Any],
) -> dict[int, str]:
"""Send a batch of strings to Claude via `claude -p`.
Returns a dict mapping batch index to translated string.
"""
claude_bin = shutil.which("claude")
if not claude_bin:
raise RuntimeError(
"claude CLI not found. Install Claude Code or add it to PATH."
)
prompt = build_prompt(target_lang, batch, index)
# Pipe the prompt over stdin rather than passing it as argv: a single batch
# with many reference languages can grow into the tens of KB and approach
# ARG_MAX on some platforms.
# claude_bin is resolved via shutil.which — not user-controlled input
result = subprocess.run( # noqa: S603
[claude_bin, "--model", model, "-p"],
input=prompt,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
f"claude exited with code {result.returncode}:\n{result.stderr}"
)
return parse_response(result.stdout.strip(), len(batch))
def _apply_plural_translation(entry: polib.POEntry, translation: str) -> None:
"""Distribute a model response across the entry's plural forms.
Model may return a JSON dict ({"0": "form0", "1": "form1"}), a JSON list
(["form0", "form1"], also valid since plural forms are ordered), a JSON
scalar (a single translation that fills every form), or a plain non-JSON
string (older models that ignore the JSON instruction).
"""
try:
plural_value = json.loads(translation)
except (json.JSONDecodeError, ValueError):
for k in entry.msgstr_plural:
entry.msgstr_plural[k] = translation
return
if isinstance(plural_value, dict):
entry.msgstr_plural = {int(k): str(v) for k, v in plural_value.items()}
return
if isinstance(plural_value, list) and plural_value:
# Distribute list items across plural form indices in order; if the
# model returned fewer forms than the language requires, repeat the
# last form rather than leaving slots blank.
forms = [str(v) for v in plural_value]
for k in sorted(entry.msgstr_plural):
entry.msgstr_plural[k] = forms[k] if k < len(forms) else forms[-1]
return
# Scalar (or empty list) — broadcast to every form.
fill = str(plural_value) if plural_value not in (None, []) else translation
for k in entry.msgstr_plural:
entry.msgstr_plural[k] = fill
def _apply_translation(
entry: polib.POEntry,
translation: str,
item: dict[str, Any],
model: str,
mark_fuzzy: bool,
) -> None:
"""Write a translation string into a POEntry and add attribution."""
if entry.msgid_plural:
_apply_plural_translation(entry, translation)
else:
entry.msgstr = translation
if mark_fuzzy and "fuzzy" not in entry.flags:
entry.flags.append("fuzzy")
refs = item["context_langs"]
refs_tag = f" [refs: {', '.join(refs)}]" if refs else " [no refs]"
attribution = f"Machine-translated via backfill_po.py ({model}){refs_tag}"
if entry.tcomment:
if attribution not in entry.tcomment:
entry.tcomment = f"{entry.tcomment}\n{attribution}"
else:
entry.tcomment = attribution
def _build_batch_items(
entries: list[polib.POEntry],
index: dict[str, Any],
lang: str,
) -> list[dict[str, Any]]:
"""Convert a list of POEntries into the dict format used by translate_batch."""
items: list[dict[str, Any]] = []
for entry in entries:
if entry.msgid_plural:
item: dict[str, Any] = {
"msgid": entry.msgid,
"msgid_plural": entry.msgid_plural,
"index_key": _plural_key(entry.msgid, entry.msgid_plural),
"is_plural": True,
}
else:
item = {
"msgid": entry.msgid,
"index_key": entry.msgid,
"is_plural": False,
}
item["context_langs"] = _context_langs(item, index, lang)
item["context_count"] = len(item["context_langs"])
items.append(item)
return items
def _process_batches(
missing: list[polib.POEntry],
index: dict[str, Any],
lang: str,
batch_size: int,
model: str,
dry_run: bool,
mark_fuzzy: bool,
cat: polib.POFile | None = None,
po_path: Path | None = None,
) -> tuple[int, int]:
"""Translate missing entries in batches. Returns (translated, failed) counts.
When ``cat`` and ``po_path`` are provided and ``dry_run`` is False, the
catalog is saved to disk after each batch that produced at least one
successful translation. This means a crash mid-run only loses the in-flight
batch rather than every batch translated so far.
"""
translated_count = 0
failed_count = 0
for batch_start in range(0, len(missing), batch_size):
batch_entries = missing[batch_start : batch_start + batch_size]
batch_items = _build_batch_items(batch_entries, index, lang)
end = min(batch_start + batch_size, len(missing))
print(
f" Translating entries {batch_start + 1}{end} of {len(missing)}",
file=sys.stderr,
)
try:
translations = translate_batch(model, lang, batch_items, index)
except (ValueError, RuntimeError) as exc:
print(f" ERROR in batch starting at {batch_start}: {exc}", file=sys.stderr)
failed_count += len(batch_entries)
continue
batch_applied = 0
for i, entry in enumerate(batch_entries):
translation = translations.get(i)
if translation is None:
print(
f" WARNING: no translation returned for index {i} "
f"(msgid: {entry.msgid[:60]!r})",
file=sys.stderr,
)
failed_count += 1
continue
if dry_run:
ctx = batch_items[i]["context_count"]
ctx_tag = f" [ctx:{ctx}]" if ctx < 3 else ""
print(
f" [{lang}]{ctx_tag} {entry.msgid[:60]!r}{translation[:60]!r}"
)
else:
_apply_translation(
entry, translation, batch_items[i], model, mark_fuzzy
)
batch_applied += 1
translated_count += 1
if (
not dry_run
and batch_applied > 0
and cat is not None
and po_path is not None
):
cat.save()
print(
f" Saved {po_path} ({batch_applied} entry(ies) in this batch).",
file=sys.stderr,
)
return translated_count, failed_count
def backfill(
lang: str,
*,
batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None,
min_context: int = 0,
model: str = DEFAULT_MODEL,
index_path: Path = DEFAULT_INDEX,
dry_run: bool = False,
mark_fuzzy: bool = True,
) -> None:
"""Backfill missing translations in the target language's .po file."""
# Defense against path traversal: ``lang`` lands in a filesystem path
# without further sanitization, so reject anything that isn't an
# ISO 639-1/639-2 code with an optional ISO 3166 region (e.g. ``pt_BR``).
if not re.fullmatch(r"[a-z]{2,3}(_[A-Z]{2})?", lang):
print(
f"Invalid language code: {lang!r} "
"(expected ISO 639 code, optionally with _<REGION>, e.g. 'fr' or 'pt_BR')",
file=sys.stderr,
)
sys.exit(1)
po_path = TRANSLATIONS_DIR / lang / "LC_MESSAGES" / "messages.po"
if not po_path.exists():
print(f"No .po file found for language '{lang}': {po_path}", file=sys.stderr)
sys.exit(1)
if not index_path.exists():
print(
f"Translation index not found at {index_path}.\n"
"Run: python scripts/translations/build_translation_index.py",
file=sys.stderr,
)
sys.exit(1)
print("Loading translation index …", file=sys.stderr)
with open(index_path, encoding="utf-8") as f:
index: dict[str, Any] = json.load(f)
print(f"Loading {po_path}", file=sys.stderr)
cat = polib.pofile(str(po_path))
missing: list[polib.POEntry] = [e for e in cat if e.msgid and _is_missing(e)]
print(f"Found {len(missing)} untranslated entries for '{lang}'.", file=sys.stderr)
if min_context > 0:
before = len(missing)
missing = [
e
for e in missing
if _context_count(
{
"index_key": (
_plural_key(e.msgid, e.msgid_plural)
if e.msgid_plural
else e.msgid
)
},
index,
lang,
)
>= min_context
]
skipped = before - len(missing)
print(
f"Skipping {skipped} entries with fewer than {min_context} reference "
f"translation(s) (use --min-context 0 to include them).",
file=sys.stderr,
)
if limit is not None:
missing = missing[:limit]
print(f"Limiting to {limit} entries.", file=sys.stderr)
if not missing:
print("Nothing to do.", file=sys.stderr)
return
translated_count, failed_count = _process_batches(
missing,
index,
lang,
batch_size,
model,
dry_run,
mark_fuzzy,
cat=cat,
po_path=po_path,
)
print(
f"\nDone. Translated: {translated_count}, Failed/skipped: {failed_count}.",
file=sys.stderr,
)
if not dry_run and translated_count > 0:
print(
f"Translations written to {po_path} (marked #, fuzzy for review).",
file=sys.stderr,
)
def main() -> None:
"""Parse CLI arguments and run translation backfill."""
parser = argparse.ArgumentParser(
description="Backfill missing .po translations using Claude AI",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--lang", required=True, help="ISO language code (e.g. fr, de, ja)"
)
parser.add_argument(
"--batch-size",
type=int,
default=DEFAULT_BATCH_SIZE,
help=f"Strings per Claude request (default: {DEFAULT_BATCH_SIZE})",
)
parser.add_argument(
"--limit",
type=int,
default=None,
help="Maximum number of entries to translate (default: unlimited)",
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=f"Claude model ID (default: {DEFAULT_MODEL})",
)
parser.add_argument(
"--index",
type=Path,
default=DEFAULT_INDEX,
help=f"Path to translation_index.json (default: {DEFAULT_INDEX})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print translations without modifying the .po file",
)
parser.add_argument(
"--min-context",
type=int,
default=0,
metavar="N",
help=(
"Skip entries with fewer than N reference translations in other languages "
"(default: 0 = translate everything). Strings with low context are more "
"likely to be ambiguous single words or fragments — set to e.g. 2 to only "
"translate strings that have been confirmed in at least 2 other languages."
),
)
parser.add_argument(
"--no-fuzzy",
dest="mark_fuzzy",
action="store_false",
default=True,
help=(
"Do not mark generated translations as #, fuzzy. "
"WARNING: fuzzy entries are excluded from compiled .mo files. "
"Removing this flag causes AI-generated translations to be served "
"to end users without human review — only use after you have "
"manually verified the .po file."
),
)
args = parser.parse_args()
backfill(
lang=args.lang,
batch_size=args.batch_size,
limit=args.limit,
min_context=args.min_context,
model=args.model,
index_path=args.index,
dry_run=args.dry_run,
mark_fuzzy=args.mark_fuzzy,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,153 @@
# 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.
"""Build a cross-language translation index from all .po files.
Outputs a JSON file structured as:
{
"<msgid>": {
"<lang>": "<translated string or null>",
...
},
...
}
For plural entries the key is "<msgid>\x00<msgid_plural>" and the value
is a dict mapping lang -> {0: "...", 1: "..."} (or null if untranslated).
Usage:
python scripts/translations/build_translation_index.py
python scripts/translations/build_translation_index.py \
--translations-dir superset/translations \
--output /tmp/translation_index.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
try:
import polib # type: ignore[import-untyped]
except ImportError:
print("polib is required. Install with: pip install polib", file=sys.stderr)
sys.exit(1)
TRANSLATIONS_DIR = Path(__file__).parent.parent.parent / "superset" / "translations"
DEFAULT_OUTPUT = (
Path(__file__).parent.parent.parent
/ "superset"
/ "translations"
/ "translation_index.json"
)
def _is_translated(entry: polib.POEntry) -> bool:
"""Return True if the entry has a non-empty, non-fuzzy translation."""
if "fuzzy" in entry.flags:
return False
if entry.msgid_plural:
return any(v for v in entry.msgstr_plural.values())
return bool(entry.msgstr)
def _plural_key(entry: polib.POEntry) -> str:
"""Build the combined key used for plural translation entries."""
return f"{entry.msgid}\x00{entry.msgid_plural}"
def build_index(translations_dir: Path) -> dict[str, Any]:
"""Read all .po files and build a combined translation index."""
index: dict[str, dict[str, Any]] = {}
langs = sorted(
d
for d in os.listdir(translations_dir)
if (translations_dir / d / "LC_MESSAGES" / "messages.po").exists()
and d != "en" # en has empty msgstr by convention (source = target)
)
for lang in langs:
po_path = translations_dir / lang / "LC_MESSAGES" / "messages.po"
cat = polib.pofile(str(po_path))
for entry in cat:
if not entry.msgid:
continue # skip header entry
if entry.msgid_plural:
key = _plural_key(entry)
if key not in index:
index[key] = {}
# Fuzzy entries are unreviewed (often machine-generated drafts),
# so excluding them prevents feeding unverified translations
# back into the AI backfill prompt as trusted context.
index[key][lang] = (
dict(entry.msgstr_plural) if _is_translated(entry) else None
)
else:
key = entry.msgid
if key not in index:
index[key] = {}
index[key][lang] = entry.msgstr if _is_translated(entry) else None
# Ensure every entry has a slot for every language (null if missing)
for key in index:
for lang in langs:
index[key].setdefault(lang, None)
return index
def main() -> None:
"""Parse arguments, build the translation index, and write it to disk."""
parser = argparse.ArgumentParser(
description="Build cross-language translation index"
)
parser.add_argument(
"--translations-dir",
type=Path,
default=TRANSLATIONS_DIR,
help="Path to the translations directory (default: superset/translations)",
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=DEFAULT_OUTPUT,
help=(
"Output JSON file path"
" (default: superset/translations/translation_index.json)"
),
)
args = parser.parse_args()
print(f"Reading .po files from {args.translations_dir}", file=sys.stderr)
index = build_index(args.translations_dir)
print(f"Indexed {len(index)} message IDs.", file=sys.stderr)
args.output.parent.mkdir(parents=True, exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(index, f, ensure_ascii=False, indent=2)
print(f"Written to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
# 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.
"""
Check that source-code changes don't cause translation regressions.
Usage
-----
Count non-fuzzy translated entries in all .po files and write JSON to stdout:
python check_translation_regression.py --count
Compare the current .po state against a previously-recorded baseline and fail
if any language lost translations:
python check_translation_regression.py --compare /path/to/before.json
Optionally write a markdown report to a file (used by CI to post a PR comment):
python check_translation_regression.py --compare before.json --report report.md
Use a translations directory other than the repo default (used by CI to count
against a separate base-branch worktree):
python check_translation_regression.py --count \\
--translations-dir /tmp/base-worktree/superset/translations
Typical CI workflow
-------------------
1. Create a base-branch worktree alongside the PR worktree
2. Run babel_update.sh in the base worktree (extract from BASE source)
3. Record baseline: python ... --count --translations-dir BASE_TREE > before.json
4. Run babel_update.sh in the PR worktree (extract from PR source) starting
from the same pristine BASE translations
5. Compare: python ... --compare before.json [--report report.md]
Comparing two babel_update outputs that started from the same BASE .po files
isolates regressions caused by the PR's source diff from any pre-existing
drift on the base branch.
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Optional
DEFAULT_TRANSLATIONS_DIR = (
Path(__file__).resolve().parent.parent.parent / "superset" / "translations"
)
# English .po files use empty msgstr by convention (source language == target),
# so they always show 0 translated entries and should not be checked.
SKIP_LANGS = {"en"}
def count_translated(po_file: Path) -> int:
"""Return the number of non-fuzzy translated messages in a .po file.
Raises:
subprocess.CalledProcessError: if ``msgfmt`` fails (e.g. malformed
.po file). The regression check exists to surface translation
problems, so a silent zero would defeat its purpose let the
caller see a malformed file as a hard failure.
"""
import shutil # noqa: PLC0415
msgfmt = shutil.which("msgfmt") or "msgfmt"
result = subprocess.run( # noqa: S603
[msgfmt, "--statistics", "-o", "/dev/null", str(po_file)],
capture_output=True,
text=True,
check=True,
)
# stderr: "123 translated messages, 4 fuzzy translations, 56 untranslated messages."
match = re.search(r"(\d+) translated message", result.stderr)
if not match:
raise RuntimeError(
f"Could not parse msgfmt --statistics output for {po_file}: "
f"{result.stderr!r}"
)
return int(match.group(1))
def get_counts(translations_dir: Path) -> dict[str, int]:
counts: dict[str, int] = {}
for po_file in sorted(translations_dir.glob("*/LC_MESSAGES/messages.po")):
lang = po_file.parent.parent.name
if lang in SKIP_LANGS:
continue
try:
counts[lang] = count_translated(po_file)
except (subprocess.CalledProcessError, RuntimeError) as exc:
# A malformed .po file (msgfmt non-zero exit, or stderr we
# can't parse) is a real problem worth seeing, but it shouldn't
# take the whole regression check down with it — that would
# hide every other language's status. Skip and warn instead;
# the missing lang will not appear in the comparison output.
print(
f"WARNING: skipping {lang}{po_file} could not be counted: {exc}",
file=sys.stderr,
)
return counts
def build_regression_report(regressions: list[tuple[str, int, int]]) -> str:
"""Build a markdown report for posting as a PR comment."""
rows = "\n".join(
f"| `{lang}` | {b} | {a} | -{b - a} |" for lang, b, a in regressions
)
affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions)
return (
"## ⚠️ Translation Regression Detected\n\n"
f"This PR causes existing translations to become fuzzy or be removed "
f"in {affected}. Please fix the affected `.po` files before merging.\n\n"
"| Language | Before | After | Lost |\n"
"|----------|-------:|------:|-----:|\n"
f"{rows}\n\n"
"### How to fix\n\n"
"**1. Install dependencies** (if not already set up):\n\n"
"```bash\n"
"pip install -r superset/translations/requirements.txt\n"
"sudo apt-get install gettext # or: brew install gettext\n"
"```\n\n"
"**2. Re-extract strings and sync `.po` files:**\n\n"
"```bash\n"
"./scripts/translations/babel_update.sh\n"
"```\n\n"
"This rewrites `superset/translations/messages.pot` from the current "
"source files and merges the changes into every `.po` file. Strings "
"whose `msgid` changed will be marked `#, fuzzy`.\n\n"
f"**3. Resolve the fuzzy entries** in the affected language files "
f"({affected}):\n\n"
"```bash\n"
"grep -n '#, fuzzy' superset/translations/<lang>/LC_MESSAGES/messages.po\n"
"```\n\n"
"For each fuzzy entry, either rewrite the `msgstr` to match the new "
"string and remove the `#, fuzzy` line, or clear the `msgstr` to "
'`""` if you cannot provide a translation.\n\n'
"**4. Commit your changes to the `.po` files.**\n"
)
def cmd_count(translations_dir: Path) -> None:
counts = get_counts(translations_dir)
print(json.dumps(counts, indent=2))
def cmd_compare(
before_path: str,
translations_dir: Path,
report_path: Optional[str] = None,
) -> None:
with open(before_path) as f:
before: dict[str, int] = json.load(f)
after = get_counts(translations_dir)
regressions: list[tuple[str, int, int]] = []
for lang, before_count in sorted(before.items()):
after_count = after.get(lang, 0)
if after_count < before_count:
regressions.append((lang, before_count, after_count))
if regressions:
print("Translation regression detected!\n")
for lang, b, a in regressions:
lost = b - a
print(f" {lang}: {b} -> {a} (-{lost} string(s) became fuzzy or removed)")
print(
"\nStrings renamed or deleted by this PR invalidated existing translations."
)
print(
"Update the affected .po files to restore the lost entries before merging."
)
if report_path:
Path(report_path).write_text(
build_regression_report(regressions), encoding="utf-8"
)
sys.exit(1)
# All good — print a summary so it's easy to read in CI logs.
print("No translation regressions.\n")
for lang in sorted(after):
b = before.get(lang, 0)
a = after[lang]
if a > b:
delta = f"+{a - b}"
elif a == b:
delta = "no change"
else:
delta = f"-{b - a}"
print(f" {lang}: {b} -> {a} ({delta})")
def main() -> None:
parser = argparse.ArgumentParser(
description="Check for translation regressions in .po files."
)
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument(
"--count",
action="store_true",
help="Output translation counts per language as JSON.",
)
action.add_argument(
"--compare",
metavar="BEFORE_JSON",
help="Compare current counts against a baseline JSON file.",
)
parser.add_argument(
"--report",
metavar="REPORT_MD",
help="When --compare detects regressions, write a markdown report here.",
)
parser.add_argument(
"--translations-dir",
type=Path,
default=DEFAULT_TRANSLATIONS_DIR,
help=(
"Path to the translations directory containing per-language "
"LC_MESSAGES/messages.po files (default: <repo>/superset/translations)."
),
)
args = parser.parse_args()
if args.count:
cmd_count(args.translations_dir)
else:
cmd_compare(args.compare, args.translations_dir, args.report)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -48,7 +48,7 @@ dependencies = [
"pydantic>=2.8.0",
"sqlalchemy>=1.4.0,<2.0",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=28.10.0, <29",
"sqlglot>=30.8.0, <31",
"typing-extensions>=4.0.0",
]
@@ -1,100 +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 {
waitForChartLoad,
ChartSpec,
getChartAliasesBySpec,
} from 'cypress/utils';
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { WORLD_HEALTH_CHARTS } from './utils';
import { isLegacyResponse } from '../../utils/vizPlugins';
describe('Dashboard top-level controls', () => {
beforeEach(() => {
cy.visit(WORLD_HEALTH_DASHBOARD);
});
// flaky test - query completes before assertion
it.skip('should allow chart level refresh', () => {
const mapSpec = WORLD_HEALTH_CHARTS.find(
({ viz }) => viz === 'world_map',
) as ChartSpec;
waitForChartLoad(mapSpec).then(gridComponent => {
const mapId = gridComponent.attr('data-test-chart-id');
cy.get('[data-test="grid-container"]').find('.world_map').should('exist');
cy.get(`#slice_${mapId}-controls`).click();
cy.get(`[data-test="slice_${mapId}-menu"]`)
.find('[data-test="refresh-chart-menu-item"]')
.click({ force: true });
// likely cause for flakiness:
// The query completes before this assertion happens.
// Solution: pause the network before clicking, assert, then unpause network.
cy.get('[data-test="refresh-chart-menu-item"]').should(
'have.class',
'ant-dropdown-menu-item-disabled',
);
waitForChartLoad(mapSpec);
cy.get('[data-test="refresh-chart-menu-item"]').should(
'not.have.class',
'ant-dropdown-menu-item-disabled',
);
});
});
it('should allow dashboard level force refresh', () => {
// when charts are not start loading, for example, under a secondary tab,
// should allow force refresh
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
getChartAliasesBySpec(WORLD_HEALTH_CHARTS).then(aliases => {
cy.get('[aria-label="ellipsis"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
'not.have.class',
'ant-dropdown-menu-item-disabled',
);
cy.get('[data-test="refresh-dashboard-menu-item"]').click({
force: true,
});
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
'have.class',
'ant-dropdown-menu-item-disabled',
);
// wait all charts force refreshed.
cy.wait(aliases).then(xhrs => {
xhrs.forEach(async ({ response, request }) => {
const responseBody = response?.body;
const isCached = isLegacyResponse(responseBody)
? responseBody.is_cached
: responseBody.result[0].is_cached;
// request url should indicate force-refresh operation
expect(request.url).to.have.string('force=true');
// is_cached in response should be false
expect(isCached).to.equal(false);
});
});
});
cy.get('[aria-label="ellipsis"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').and(
'not.have.class',
'ant-dropdown-menu-item-disabled',
);
});
});
@@ -1,292 +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 { nativeFilters } from 'cypress/support/directories';
import {
addCountryNameFilter,
applyNativeFilterValueWithIndex,
enterNativeFilterEditModal,
inputNativeFilterDefaultValue,
saveNativeFilterSettings,
validateFilterNameOnDashboard,
testItems,
interceptFilterState,
} from './utils';
import {
prepareDashboardFilters,
SAMPLE_CHART,
visitDashboard,
} from './shared_dashboard_functions';
function openMoreFilters(waitFilterState = true) {
interceptFilterState();
// Wait for the dropdown button to appear when filters are overflowed
// The button only appears when there are overflowed filters
cy.getBySel('dropdown-container-btn', { timeout: 10000 })
.should('exist')
.should('be.visible')
.click({ force: true });
if (waitFilterState) {
cy.wait('@postFilterState');
}
}
function openVerticalFilterBar() {
cy.getBySel('dashboard-filters-panel').should('exist');
cy.getBySel('filter-bar__expand-button').click();
}
function setFilterBarOrientation(orientation: 'vertical' | 'horizontal') {
cy.getBySel('filterbar-orientation-icon').click();
cy.wait(250);
cy.get('.filter-bar-orientation-submenu')
.contains('Orientation of filter bar')
.should('exist')
.trigger('mouseover');
if (orientation === 'vertical') {
cy.get('.ant-dropdown-menu-item-selected')
.contains('Horizontal (Top)')
.should('exist');
cy.get('.ant-dropdown-menu-item').contains('Vertical (Left)').click();
cy.getBySel('dashboard-filters-panel').should('exist');
} else {
cy.get('.ant-dropdown-menu-item-selected')
.contains('Vertical (Left)')
.should('exist');
cy.get('.ant-dropdown-menu-item').contains('Horizontal (Top)').click();
cy.getBySel('loading-indicator').should('exist');
cy.getBySel('filter-bar').should('exist');
cy.getBySel('dashboard-filters-panel').should('not.exist');
}
}
describe('Horizontal FilterBar', () => {
it('should go from vertical to horizontal and the opposite', () => {
visitDashboard();
openVerticalFilterBar();
setFilterBarOrientation('horizontal');
setFilterBarOrientation('vertical');
});
it('should show all default actions in horizontal mode', () => {
visitDashboard();
openVerticalFilterBar();
setFilterBarOrientation('horizontal');
cy.getBySel('horizontal-filterbar-empty')
.contains('No filters are currently added to this dashboard.')
.should('exist');
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
cy.get('.ant-dropdown-menu').should('be.visible');
cy.getBySel('filter-bar__create-filter').should('exist');
cy.getBySel('filterbar-action-buttons').should('exist');
});
it('should stay in horizontal mode when reloading', () => {
visitDashboard();
openVerticalFilterBar();
setFilterBarOrientation('horizontal');
cy.reload();
cy.getBySel('dashboard-filters-panel').should('not.exist');
});
it('should show all filters in available space on load', () => {
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
{ name: 'test_2', column: 'country_code', datasetId: 2 },
{ name: 'test_3', column: 'region', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
cy.get('.filter-item-wrapper').should('have.length', 3);
});
it('should show "more filters" on window resizing up and down', () => {
// Use 4 filters with unique columns to ensure overflow testing while allowing all to fit at large viewport
prepareDashboardFilters([
{ name: 'Country', column: 'country_name', datasetId: 2 },
{ name: 'Code', column: 'country_code', datasetId: 2 },
{ name: 'Region', column: 'region', datasetId: 2 },
{ name: 'Year', column: 'year', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
// At full width, check how many filters are visible in main bar
cy.get('.filter-item-wrapper').then($items => {
cy.log(`Found ${$items.length} filter items at full width`);
});
// Resize to force overflow
cy.viewport(500, 1024);
cy.wait(500); // Allow layout to stabilize after viewport change
// Should have some filters visible and dropdown button present
cy.get('.filter-item-wrapper').should('have.length.lessThan', 4);
cy.getBySel('dropdown-container-btn').should('exist');
// Open more filters and verify all are accessible in the dropdown
openMoreFilters(false);
// Check that the dropdown content contains filters
cy.getBySel('dropdown-content').within(() => {
cy.getBySel('form-item-value').should('have.length.greaterThan', 0);
});
// Close the dropdown
cy.getBySel('filter-bar').click();
// Test with medium viewport
cy.viewport(800, 1024);
cy.wait(500); // Allow layout to stabilize after viewport change
// May or may not have overflow at this size - test adaptively
cy.get('body').then($body => {
if ($body.find('[data-test="dropdown-container-btn"]').length > 0) {
openMoreFilters(false);
cy.getBySel('dropdown-content').within(() => {
cy.getBySel('form-item-value').should('have.length.greaterThan', 0);
});
cy.getBySel('filter-bar').click(); // Close dropdown
}
});
// At large viewport, all filters should fit
cy.viewport(1300, 1024);
cy.wait(500); // Allow layout to stabilize after viewport change
cy.get('.filter-item-wrapper').then($items => {
cy.log(`Found ${$items.length} filter items at large width`);
// Just verify we have some filters, don't assert exact count
expect($items.length).to.be.greaterThan(0);
});
cy.getBySel('dropdown-container-btn').should('not.exist');
});
it('should show "more filters" and scroll', () => {
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
{ name: 'test_2', column: 'country_code', datasetId: 2 },
{ name: 'test_3', column: 'region', datasetId: 2 },
{ name: 'test_4', column: 'year', datasetId: 2 },
{ name: 'test_5', column: 'country_name', datasetId: 2 },
{ name: 'test_6', column: 'country_code', datasetId: 2 },
{ name: 'test_7', column: 'region', datasetId: 2 },
{ name: 'test_8', column: 'year', datasetId: 2 },
{ name: 'test_9', column: 'country_name', datasetId: 2 },
{ name: 'test_10', column: 'country_code', datasetId: 2 },
{ name: 'test_11', column: 'region', datasetId: 2 },
{ name: 'test_12', column: 'year', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
cy.get('.filter-item-wrapper').should('have.length', 4);
openMoreFilters();
cy.getBySel('form-item-value').should('have.length', 12);
cy.getBySel('filter-control-name').contains('test_3').should('be.visible');
cy.getBySel('filter-control-name')
.contains('test_12')
.should('not.be.visible');
cy.getBySel('filter-control-name').contains('test_12').scrollIntoView();
cy.getBySel('filter-control-name').contains('test_12').should('be.visible');
});
it('should display newly added filter', () => {
visitDashboard();
openVerticalFilterBar();
setFilterBarOrientation('horizontal');
enterNativeFilterEditModal(false);
addCountryNameFilter();
saveNativeFilterSettings([]);
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
});
it.skip('should spot changes in "more filters" and apply their values', () => {
cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart');
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
{ name: 'test_2', column: 'country_code', datasetId: 2 },
{ name: 'test_3', column: 'region', datasetId: 2 },
{ name: 'test_4', column: 'year', datasetId: 2 },
{ name: 'test_5', column: 'country_name', datasetId: 2 },
{ name: 'test_6', column: 'country_code', datasetId: 2 },
{ name: 'test_7', column: 'region', datasetId: 2 },
{ name: 'test_8', column: 'year', datasetId: 2 },
{ name: 'test_9', column: 'country_name', datasetId: 2 },
{ name: 'test_10', column: 'country_code', datasetId: 2 },
{ name: 'test_11', column: 'region', datasetId: 2 },
{ name: 'test_12', column: 'year', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
openMoreFilters();
applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue);
cy.get(nativeFilters.applyFilter).click({ force: true });
cy.wait('@chart');
cy.get('.ant-scroll-number.ant-badge-count').should(
'have.attr',
'title',
'1',
);
});
it.skip('should focus filter and open "more filters" programmatically', () => {
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
{ name: 'test_2', column: 'country_code', datasetId: 2 },
{ name: 'test_3', column: 'region', datasetId: 2 },
{ name: 'test_4', column: 'year', datasetId: 2 },
{ name: 'test_5', column: 'country_name', datasetId: 2 },
{ name: 'test_6', column: 'country_code', datasetId: 2 },
{ name: 'test_7', column: 'region', datasetId: 2 },
{ name: 'test_8', column: 'year', datasetId: 2 },
{ name: 'test_9', column: 'country_name', datasetId: 2 },
{ name: 'test_10', column: 'country_code', datasetId: 2 },
{ name: 'test_11', column: 'region', datasetId: 2 },
{ name: 'test_12', column: 'year', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
openMoreFilters();
applyNativeFilterValueWithIndex(8, testItems.filterDefaultValue);
cy.get(nativeFilters.applyFilter).click({ force: true });
cy.getBySel('slice-header').within(() => {
cy.get('.filter-counts').trigger('mouseover');
});
cy.getBySel('filter-status-popover').contains('test_9').click();
cy.getBySel('dropdown-content').should('be.visible');
cy.get('.ant-select-focused').should('be.visible');
});
it.skip('should show tag count and one plain tag on focus and only count on blur in select ', () => {
prepareDashboardFilters([
{ name: 'test_1', column: 'country_name', datasetId: 2 },
]);
setFilterBarOrientation('horizontal');
enterNativeFilterEditModal();
inputNativeFilterDefaultValue('Albania');
cy.get('.ant-select-selection-search-input').clear({ force: true });
inputNativeFilterDefaultValue('Algeria', true);
saveNativeFilterSettings([SAMPLE_CHART]);
cy.getBySel('filter-bar').within(() => {
cy.get(nativeFilters.filterItem).contains('Albania').should('be.visible');
cy.get(nativeFilters.filterItem).contains('+ 1 ...').should('be.visible');
cy.get('.ant-select-selection-search-input').click();
cy.get(nativeFilters.filterItem).contains('+ 2 ...').should('be.visible');
});
});
});
@@ -1,53 +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 qs from 'querystringify';
import { waitForChartLoad } from 'cypress/utils';
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { WORLD_HEALTH_CHARTS } from './utils';
interface QueryString {
native_filters_key: string;
}
describe('nativefilter url param key', () => {
// const urlParams = { param1: '123', param2: 'abc' };
let initialFilterKey: string;
it('should have cachekey in nativefilter param', () => {
// things in `before` will not retry and the `waitForChartLoad` check is
// especially flaky and may need more retries
cy.visit(WORLD_HEALTH_DASHBOARD);
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
cy.wait(1000); // wait for key to be published (debounced)
cy.location().then(loc => {
const queryParams = qs.parse(loc.search) as QueryString;
expect(typeof queryParams.native_filters_key).eq('string');
});
});
it('should have different key when page reloads', () => {
cy.visit(WORLD_HEALTH_DASHBOARD);
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
cy.wait(1000); // wait for key to be published (debounced)
cy.location().then(loc => {
const queryParams = qs.parse(loc.search) as QueryString;
expect(queryParams.native_filters_key).not.equal(initialFilterKey);
});
});
});
@@ -1,51 +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 { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { waitForChartLoad } from 'cypress/utils';
import { WORLD_HEALTH_CHARTS, interceptLog } from './utils';
describe('Dashboard load', () => {
it('should load dashboard', () => {
cy.visit(WORLD_HEALTH_DASHBOARD);
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
});
it('should load in edit mode', () => {
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
cy.getBySel('discard-changes-button').should('be.visible');
});
it('should load in standalone mode', () => {
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
cy.get('#app-menu').should('not.exist');
});
it('should load in edit/standalone mode', () => {
cy.visit(`${WORLD_HEALTH_DASHBOARD}?edit=true&standalone=true`);
cy.getBySel('discard-changes-button').should('be.visible');
cy.get('#app-menu').should('not.exist');
});
// TODO flaky test. skipping to unblock CI
it.skip('should send log data', () => {
interceptLog();
cy.visit(WORLD_HEALTH_DASHBOARD);
cy.wait('@logs', { timeout: 15000 });
});
});
@@ -1,385 +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 qs from 'querystring';
import {
dashboardView,
nativeFilters,
dataTestChartName,
} from 'cypress/support/directories';
import {
addCountryNameFilter,
applyAdvancedTimeRangeFilterOnDashboard,
applyNativeFilterValueWithIndex,
cancelNativeFilterSettings,
deleteNativeFilter,
enterNativeFilterEditModal,
fillNativeFilterForm,
inputNativeFilterDefaultValue,
saveNativeFilterSettings,
undoDeleteNativeFilter,
validateFilterContentOnDashboard,
validateFilterNameOnDashboard,
testItems,
WORLD_HEALTH_CHARTS,
} from './utils';
import {
prepareDashboardFilters,
SAMPLE_CHART,
visitDashboard,
} from './shared_dashboard_functions';
// function selectFilter(index: number) {
// cy.get("[data-test='filter-title-container'] [draggable='true']")
// .eq(index)
// .click();
// }
// function closeFilterModal() {
// cy.get('body').then($body => {
// if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
// cy.getBySel('native-filter-modal-cancel-button').click();
// }
// });
// }
describe('Native filters', () => {
describe('Nativefilters initial state not required', () => {
it("User can check 'Filter has default value'", () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
});
it('User can add a new native filter', () => {
prepareDashboardFilters([]);
let filterKey: string;
const removeFirstChar = (search: string) =>
search.split('').slice(1, search.length).join('');
cy.location().then(loc => {
cy.url().should('contain', 'native_filters_key');
const queryParams = qs.parse(removeFirstChar(loc.search));
filterKey = queryParams.native_filters_key as string;
expect(typeof filterKey).eq('string');
});
enterNativeFilterEditModal();
addCountryNameFilter();
saveNativeFilterSettings([SAMPLE_CHART]);
cy.location().then(loc => {
cy.url().should('contain', 'native_filters_key');
const queryParams = qs.parse(removeFirstChar(loc.search));
const newfilterKey = queryParams.native_filters_key;
expect(newfilterKey).eq(filterKey);
});
cy.get(nativeFilters.modal.container).should('not.exist');
});
it('User can restore a deleted native filter', () => {
prepareDashboardFilters([
{ name: 'country_code', column: 'country_code', datasetId: 2 },
]);
enterNativeFilterEditModal();
cy.get(nativeFilters.filtersList.removeIcon).first().click();
cy.get('[data-test="restore-filter-button"]')
.should('be.visible')
.click();
cy.get(nativeFilters.modal.container)
.find(nativeFilters.filtersPanel.filterName)
.should(
'have.attr',
'value',
testItems.topTenChart.filterColumnCountryCode,
);
});
it('User can create a time grain filter', () => {
prepareDashboardFilters([]);
enterNativeFilterEditModal();
fillNativeFilterForm(
testItems.filterType.timeGrain,
testItems.filterType.timeGrain,
testItems.datasetForNativeFilter,
);
saveNativeFilterSettings([SAMPLE_CHART]);
applyNativeFilterValueWithIndex(0, testItems.filterTimeGrain);
cy.get(nativeFilters.applyFilter).click();
cy.url().then(u => {
const ur = new URL(u);
expect(ur.search).to.include('native_filters');
});
validateFilterNameOnDashboard(testItems.filterType.timeGrain);
validateFilterContentOnDashboard(testItems.filterTimeGrain);
});
it.skip('User can create a time range filter', () => {
enterNativeFilterEditModal();
fillNativeFilterForm(
testItems.filterType.timeRange,
testItems.filterType.timeRange,
);
saveNativeFilterSettings(WORLD_HEALTH_CHARTS);
cy.get(dashboardView.salesDashboardSpecific.vehicleSalesFilterTimeRange)
.should('be.visible')
.click();
applyAdvancedTimeRangeFilterOnDashboard('2005-12-17', '2006-12-17');
cy.url().then(u => {
const ur = new URL(u);
expect(ur.search).to.include('native_filters');
});
validateFilterNameOnDashboard(testItems.filterType.timeRange);
cy.get(nativeFilters.filterFromDashboardView.timeRangeFilterContent)
.contains('2005-12-17')
.should('be.visible');
});
it.skip('User can create a time column filter', () => {
enterNativeFilterEditModal();
fillNativeFilterForm(
testItems.filterType.timeColumn,
testItems.filterType.timeColumn,
testItems.datasetForNativeFilter,
);
saveNativeFilterSettings(WORLD_HEALTH_CHARTS);
cy.intercept(`**/api/v1/chart/data?form_data=**`).as('chart');
cy.get(nativeFilters.modal.container).should('not.exist');
// assert that native filter is created
validateFilterNameOnDashboard(testItems.filterType.timeColumn);
applyNativeFilterValueWithIndex(
0,
testItems.topTenChart.filterColumnYear,
);
cy.get(nativeFilters.applyFilter).click({ force: true });
cy.wait('@chart');
validateFilterContentOnDashboard(testItems.topTenChart.filterColumnYear);
});
describe.only('Numerical Range Filter - Display Modes', () => {
beforeEach(() => {
visitDashboard();
});
const expandFilterConfiguration = () => {
cy.get('.ant-collapse-header')
.contains('Filter Configuration')
.should('be.visible')
.then($header => {
cy.wrap($header)
.closest('.ant-collapse-item')
.invoke('hasClass', 'ant-collapse-item-active')
.then(isExpanded => {
if (!isExpanded) cy.wrap($header).click();
});
});
cy.get('.ant-collapse-content-box').should('be.visible');
};
const selectRangeTypeOption = (label: string) => {
cy.contains('Range Type')
.should('be.visible')
.closest('.ant-form-item')
.within(() => {
cy.get('.ant-select-selector').click();
});
cy.get('.ant-select-dropdown:visible')
.contains('.ant-select-item-option', label)
.click();
};
const applyAndAssertInputs = (from: string, to: string) => {
// Set 'from' input
cy.get('[data-test="range-filter-from-input"]').clear();
cy.get('[data-test="range-filter-from-input"]').type(from);
cy.get('[data-test="range-filter-from-input"]').blur();
// Set 'to' input
cy.get('[data-test="range-filter-to-input"]').clear();
cy.get('[data-test="range-filter-to-input"]').type(to);
cy.get('[data-test="range-filter-to-input"]').blur();
// Assert values without chaining after .invoke()
cy.get('[data-test="range-filter-from-input"]')
.invoke('val')
.then(val => {
expect(val).to.equal(from);
});
cy.get('[data-test="range-filter-to-input"]')
.invoke('val')
.then(val => {
expect(val).to.equal(to);
});
};
it('User can create a numerical range filter with "Range Inputs" display mode', () => {
enterNativeFilterEditModal(false);
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
selectRangeTypeOption('Range Inputs');
saveNativeFilterSettings([]);
cy.wait(500); // allow filter to mount
applyAndAssertInputs('40', '70');
});
it('User can change the display mode to "Slider"', () => {
enterNativeFilterEditModal(false);
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
cy.contains('Range Type')
.should('be.visible')
.closest('.ant-form-item')
.within(() => {
cy.get('.ant-select-selector').click({ force: true });
});
cy.get('.ant-select-dropdown:visible .ant-select-item-option')
.contains(/^Slider$/)
.click({ force: true });
cy.get('.ant-select-selector').should('contain.text', 'Slider');
saveNativeFilterSettings([]);
cy.get('.ant-slider', { timeout: 10000 }).should('be.visible');
cy.get('[data-test="range-filter-from-input"]', {
timeout: 5000,
}).should('not.exist');
cy.get('[data-test="range-filter-to-input"]', { timeout: 5000 }).should(
'not.exist',
);
});
it('User can change the display mode to "Slider and range input"', () => {
enterNativeFilterEditModal(false);
// Re-create filter
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
selectRangeTypeOption('Slider and range input');
saveNativeFilterSettings([]);
cy.wait(500);
applyAndAssertInputs('40', '70');
});
});
it('User can undo deleting a native filter', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
undoDeleteNativeFilter();
cy.get(nativeFilters.modal.container)
.find(nativeFilters.filtersPanel.filterName)
.should('have.attr', 'value', testItems.topTenChart.filterColumn);
});
it('User can cancel changes in native filter', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
cy.getBySel('filters-config-modal__name-input').type('|EDITED', {
force: true,
});
cancelNativeFilterSettings();
enterNativeFilterEditModal(false);
cy.get(nativeFilters.filtersList.removeIcon).first().click();
cy.contains('You have removed this filter.').should('be.visible');
});
it('User can create a value filter', () => {
visitDashboard();
enterNativeFilterEditModal(false);
addCountryNameFilter();
cy.get(nativeFilters.filtersPanel.filterTypeInput)
.find(nativeFilters.filtersPanel.filterTypeItem)
.should('have.text', testItems.filterType.value);
saveNativeFilterSettings([]);
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
});
it('User can apply value filter with selected values', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
applyNativeFilterValueWithIndex(0, testItems.filterDefaultValue);
cy.get(nativeFilters.applyFilter).click();
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('not.exist');
});
});
it('User can stop filtering when filter is removed', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
saveNativeFilterSettings([SAMPLE_CHART]);
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('not.exist');
});
cy.get(nativeFilters.filterItem)
.contains(testItems.filterDefaultValue)
.should('be.visible');
validateFilterNameOnDashboard(testItems.topTenChart.filterColumn);
enterNativeFilterEditModal(false);
deleteNativeFilter();
saveNativeFilterSettings([SAMPLE_CHART]);
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('be.visible');
});
});
});
});
@@ -1,431 +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 {
nativeFilters,
exploreView,
dataTestChartName,
} from 'cypress/support/directories';
import {
addParentFilterWithValue,
applyNativeFilterValueWithIndex,
cancelNativeFilterSettings,
checkNativeFilterTooltip,
clickOnAddFilterInModal,
collapseFilterOnLeftPanel,
enterNativeFilterEditModal,
expandFilterOnLeftPanel,
getNativeFilterPlaceholderWithIndex,
inputNativeFilterDefaultValue,
saveNativeFilterSettings,
nativeFilterTooltips,
validateFilterContentOnDashboard,
valueNativeFilterOptions,
validateFilterNameOnDashboard,
testItems,
} from './utils';
import {
prepareDashboardFilters,
SAMPLE_CHART,
visitDashboard,
} from './shared_dashboard_functions';
function selectFilter(index: number) {
cy.get("[data-test='filter-title-container'] [role='tab']").eq(index).click();
}
function closeFilterModal() {
cy.get('body').then($body => {
if ($body.find('[data-test="native-filter-modal-cancel-button"]').length) {
cy.getBySel('native-filter-modal-cancel-button').click();
}
});
}
describe('Native filters', () => {
describe('Nativefilters tests initial state required', () => {
beforeEach(() => {
cy.createSampleDashboards([0]);
});
it.skip('Verify that default value is respected after revisit', () => {
prepareDashboardFilters([
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
inputNativeFilterDefaultValue(testItems.filterDefaultValue);
saveNativeFilterSettings([SAMPLE_CHART]);
cy.get(nativeFilters.filterItem)
.contains(testItems.filterDefaultValue)
.should('be.visible');
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('not.exist');
});
// reload dashboard
cy.reload();
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('not.exist');
});
validateFilterContentOnDashboard(testItems.filterDefaultValue);
});
it('User can create parent filters using "Values are dependent on other filters"', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
saveNativeFilterSettings([SAMPLE_CHART]);
[
testItems.topTenChart.filterColumnRegion,
testItems.topTenChart.filterColumn,
].forEach(it => {
cy.get(nativeFilters.filterFromDashboardView.filterName)
.contains(it)
.should('be.visible');
});
getNativeFilterPlaceholderWithIndex(1)
.invoke('text')
.should('equal', '214 options', { timeout: 20000 });
// apply first filter value and validate 2nd filter is depden on 1st filter.
applyNativeFilterValueWithIndex(0, 'North America');
getNativeFilterPlaceholderWithIndex(0).should('have.text', '3 options', {
timeout: 20000,
});
});
it('user can delete dependent filter', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
// remove year native filter to cause it disappears from parent filter input in global sales
cy.get(nativeFilters.modal.tabsList.removeTab)
.should('be.visible')
.first()
.click();
// make sure you are seeing global sales filter which had parent filter
cy.get(nativeFilters.modal.tabsList.filterItemsContainer)
.children()
.last()
.click();
//
cy.wait(1000);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters').should(
'not.exist',
);
},
);
});
it('user cannot create bi-directional dependencies between filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
{ name: 'country_code', column: 'country_code' },
{ name: 'year', column: 'year' },
]);
enterNativeFilterEditModal();
// First, make country_name dependent on region
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
// Second, make country_code dependent on country_name
selectFilter(2);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumn);
// Now select region filter and try to add dependency
selectFilter(0);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
// Verify that only 'year' is available as dependency for region
// 'country_name' and 'country_code' should not be available (would create circular dependency)
cy.get('input[aria-label^="Limit type"]').click({ force: true });
cy.get('[role="listbox"]').should('be.visible');
cy.get('[role="listbox"]').should('contain', 'year');
cy.get('[role="listbox"]').should('not.contain', 'country_name');
cy.get('[role="listbox"]').should('not.contain', 'country_code');
cy.get('[role="listbox"]').contains('year').click();
},
);
});
it('Dependent filter selects first item based on parent filter selection', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
selectFilter(0);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Select first filter value by default')
.should('be.visible')
.click();
},
);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Can select multiple values ')
.should('be.visible')
.click();
},
);
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Can select multiple values ')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Select first filter value by default')
.should('be.visible')
.click();
},
);
// cannot use saveNativeFilterSettings because there is a bug which
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
// to be saved when using dependent filters so,
// you reload the window.
cy.get(nativeFilters.modal.footer)
.contains('Save')
.should('be.visible')
.click({ force: true });
cy.get(nativeFilters.modal.container).should('not.exist');
cy.reload();
applyNativeFilterValueWithIndex(0, 'North America');
// Check that dependent filter auto-selects the first item
cy.get(nativeFilters.filterFromDashboardView.filterContent)
.eq(1)
.should('contain.text', 'Bermuda');
});
it('User can create filter depend on 2 other filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
{ name: 'country_code', column: 'country_code' },
]);
enterNativeFilterEditModal();
selectFilter(2);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
cy.get(exploreView.controlPanel.addFieldValue).click();
},
);
// add value to the first input
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
// add value to the second input
addParentFilterWithValue(1, testItems.topTenChart.filterColumn);
saveNativeFilterSettings([SAMPLE_CHART]);
// filters should be displayed in the left panel
[
testItems.topTenChart.filterColumnRegion,
testItems.topTenChart.filterColumn,
testItems.topTenChart.filterColumnCountryCode,
].forEach(it => {
validateFilterNameOnDashboard(it);
});
// initially first filter shows 39 options
getNativeFilterPlaceholderWithIndex(0).should('have.text', '7 options');
// initially second filter shows 409 options
getNativeFilterPlaceholderWithIndex(1).should('have.text', '214 options');
// verify third filter shows 409 options
getNativeFilterPlaceholderWithIndex(2).should('have.text', '214 options');
// apply first filter value
applyNativeFilterValueWithIndex(0, 'North America');
// verify second filter shows 409 options available still
getNativeFilterPlaceholderWithIndex(0).should('have.text', '214 options');
// verify second filter shows 69 options available still
getNativeFilterPlaceholderWithIndex(1).should('have.text', '3 options');
// apply second filter value
applyNativeFilterValueWithIndex(1, 'United States');
// verify number of available options for third filter - should be decreased to only one
getNativeFilterPlaceholderWithIndex(0).should('have.text', '1 option');
});
it('User can remove parent filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region' },
{ name: 'country_name', column: 'country_name' },
]);
enterNativeFilterEditModal();
selectFilter(1);
// Select dependent option and auto use platform for genre
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
saveNativeFilterSettings([SAMPLE_CHART]);
enterNativeFilterEditModal(false);
cy.get(nativeFilters.modal.tabsList.removeTab)
.should('be.visible')
.first()
.click({
force: true,
});
saveNativeFilterSettings([SAMPLE_CHART]);
cy.get(dataTestChartName(testItems.topTenChart.name)).within(() => {
cy.contains(testItems.filterDefaultValue).should('be.visible');
cy.contains(testItems.filterOtherCountry).should('be.visible');
});
});
});
describe('Nativefilters basic interactions', () => {
before(() => {
visitDashboard();
});
beforeEach(() => {
cy.createSampleDashboards([0]);
closeFilterModal();
});
it('User can expand / retract native filter sidebar on a dashboard', () => {
expandFilterOnLeftPanel();
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
cy.get('.ant-dropdown-menu').should('be.visible');
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).should(
'be.visible',
);
cy.get(nativeFilters.filterFromDashboardView.expand).should(
'not.be.visible',
);
collapseFilterOnLeftPanel();
});
it('User can enter filter edit pop-up by clicking on native filter edit icon', () => {
enterNativeFilterEditModal(false);
});
it('User can delete a native filter', () => {
enterNativeFilterEditModal(false);
cy.get(nativeFilters.filtersList.removeIcon).first().click();
cy.contains('Restore filter').should('not.exist', { timeout: 10000 });
});
it('User can cancel creating a new filter', () => {
enterNativeFilterEditModal(false);
cancelNativeFilterSettings();
});
it('Verify setting options and tooltips for value filter', () => {
enterNativeFilterEditModal(false);
cy.contains('Filter value is required').scrollIntoView();
cy.get('body').trigger('mousemove', { clientX: 0, clientY: 0 });
cy.wait(300);
cy.contains('Filter value is required').should('be.visible').click({
force: true,
});
checkNativeFilterTooltip(0, nativeFilterTooltips.preFilter);
checkNativeFilterTooltip(1, nativeFilterTooltips.defaultValue);
cy.get(nativeFilters.modal.container).should('be.visible');
valueNativeFilterOptions.forEach(el => {
cy.contains(el);
});
cy.contains('Values are dependent on other filters').should('not.exist');
cy.get(
nativeFilters.filterConfigurationSections.checkedCheckbox,
).contains('Can select multiple values');
checkNativeFilterTooltip(2, nativeFilterTooltips.required);
checkNativeFilterTooltip(3, nativeFilterTooltips.defaultToFirstItem);
checkNativeFilterTooltip(4, nativeFilterTooltips.searchAllFilterOptions);
checkNativeFilterTooltip(5, nativeFilterTooltips.inverseSelection);
clickOnAddFilterInModal();
cy.contains('Values are dependent on other filters').should('exist');
});
});
});
@@ -1,194 +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 {
parsePostForm,
waitForChartLoad,
getChartAliasBySpec,
} from 'cypress/utils';
import { TABBED_DASHBOARD } from 'cypress/utils/urls';
import { expandFilterOnLeftPanel } from './utils';
const TREEMAP = { name: 'Treemap', viz: 'treemap_v2' };
const LINE_CHART = { name: 'Growth Rate', viz: 'echarts_timeseries_line' };
const BOX_PLOT = { name: 'Box plot', viz: 'box_plot' };
const BIG_NUMBER = { name: 'Number of Girls', viz: 'big_number_total' };
const TABLE = { name: 'Names Sorted by Num in California', viz: 'table' };
function topLevelTabs() {
cy.getBySel('dashboard-component-tabs')
.first()
.find('[data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab')
.as('top-level-tabs');
}
function resetTabs() {
topLevelTabs();
cy.get('@top-level-tabs').first().click();
waitForChartLoad(TREEMAP);
waitForChartLoad(BIG_NUMBER);
waitForChartLoad(TABLE);
}
describe.skip('Dashboard tabs', () => {
before(() => {
cy.visit(TABBED_DASHBOARD);
});
beforeEach(() => {
resetTabs();
});
it('should switch tabs', () => {
topLevelTabs();
cy.get('@top-level-tabs').first().click();
cy.get('@top-level-tabs')
.first()
.should('have.class', 'ant-tabs-tab-active');
cy.get('@top-level-tabs')
.last()
.should('not.have.class', 'ant-tabs-tab-active');
cy.get('[data-test-chart-name="Box plot"]').should('not.exist');
cy.get('[data-test-chart-name="Trends"]').should('not.exist');
cy.get('@top-level-tabs').last().click();
cy.get('@top-level-tabs')
.last()
.should('have.class', 'ant-tabs-tab-active');
cy.get('@top-level-tabs')
.first()
.should('not.have.class', 'ant-tabs-tab-active');
waitForChartLoad(BOX_PLOT);
cy.get('[data-test-chart-name="Box plot"]').should('exist');
resetTabs();
// click row level tab, see 1 more chart
cy.getBySel('dashboard-component-tabs')
.eq(2)
.find('[data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab')
.as('row-level-tabs');
cy.get('@row-level-tabs').last().click();
waitForChartLoad(LINE_CHART);
cy.get('[data-test-chart-name="Trends"]').should('exist');
cy.get('@row-level-tabs').first().click();
});
it.skip('should send new queries when tab becomes visible', () => {
// landing in first tab
waitForChartLoad(TREEMAP);
getChartAliasBySpec(TREEMAP).then(treemapAlias => {
// apply filter
cy.get('.Select__control').first().should('be.visible').click();
cy.get('.Select__control input[type=text]').first().focus();
cy.focused().type('South');
cy.get('.Select__option').contains('South Asia').click();
cy.get('.filter button:not(:disabled)').contains('Apply').click();
// send new query from same tab
cy.wait(treemapAlias).then(({ request }) => {
const requestBody = parsePostForm(request.body);
const requestParams = JSON.parse(requestBody.form_data as string);
expect(requestParams.extra_filters[0]).deep.eq({
col: 'region',
op: 'IN',
val: ['South Asia'],
});
});
});
cy.intercept('**/superset/explore_json/?*').as('legacyChartData');
// click row level tab, send 1 more query
cy.get('.ant-tabs-tab').contains('row tab 2').click();
cy.wait('@legacyChartData').then(({ request }) => {
const requestBody = parsePostForm(request.body);
const requestParams = JSON.parse(requestBody.form_data as string);
expect(requestParams.extra_filters[0]).deep.eq({
col: 'region',
op: 'IN',
val: ['South Asia'],
});
expect(requestParams.viz_type).eq(LINE_CHART.viz);
});
cy.intercept('POST', '**/api/v1/chart/data?*').as('v1ChartData');
// click top level tab, send 1 more query
cy.get('.ant-tabs-tab').contains('Tab B').click();
cy.wait('@v1ChartData').then(({ request }) => {
expect(request.body.queries[0].filters[0]).deep.eq({
col: 'region',
op: 'IN',
val: ['South Asia'],
});
});
getChartAliasBySpec(BOX_PLOT).then(boxPlotAlias => {
// navigate to filter and clear filter
cy.get('.ant-tabs-tab').contains('Tab A').click();
cy.get('.ant-tabs-tab').contains('row tab 1').click();
cy.get('.Select__clear-indicator').click();
cy.get('.filter button:not(:disabled)').contains('Apply').click();
// trigger 1 new query
waitForChartLoad(TREEMAP);
// make sure query API not requested multiple times
cy.on('fail', err => {
expect(err.message).to.include('timed out waiting');
return false;
});
cy.wait(boxPlotAlias, { timeout: 1000 }).then(() => {
throw new Error('Unexpected API call.');
});
});
});
it('should update size when switch tab', () => {
cy.get('@top-level-tabs').last().click();
cy.get('@top-level-tabs')
.last()
.should('have.class', 'ant-tabs-tab-active');
expandFilterOnLeftPanel();
cy.wait(1000);
cy.get('@top-level-tabs').first().click();
cy.get('@top-level-tabs')
.first()
.should('have.class', 'ant-tabs-tab-active');
cy.wait(1000);
cy.get("[data-test-viz-type='treemap_v2'] .chart-container").then(
$chartContainer => {
expect($chartContainer.get(0).scrollWidth).eq(
$chartContainer.get(0).offsetWidth,
);
},
);
});
});
@@ -1,45 +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 { parsePostForm, JsonObject, waitForChartLoad } from 'cypress/utils';
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { WORLD_HEALTH_CHARTS } from './utils';
describe('Dashboard form data', () => {
const urlParams = { param1: '123', param2: 'abc' };
before(() => {
cy.visit(WORLD_HEALTH_DASHBOARD, { qs: urlParams });
});
it('should apply url params to slice requests', () => {
cy.intercept('**/api/v1/chart/data?*', request => {
// TODO: export url params to chart data API
request.body.queries.forEach((query: { url_params: JsonObject }) => {
expect(query.url_params).deep.eq(urlParams);
});
});
cy.intercept('**/superset/explore_json/*', request => {
const requestParams = JSON.parse(
parsePostForm(request.body).form_data as string,
);
expect(requestParams.url_params).deep.eq(urlParams);
});
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,109 +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.
*/
describe('AdhocFilters', () => {
beforeEach(() => {
cy.intercept('GET', '**/api/v1/datasource/table/*/column/name/values').as(
'filterValues',
);
cy.intercept('POST', '**/superset/explore_json/**').as('postJson');
cy.intercept('GET', '**/superset/explore_json/**').as('getJson');
cy.visitChartByName('Boys'); // a table chart
cy.verifySliceSuccess({ waitAlias: '@postJson' });
});
let numScripts = 0;
it('Should load AceEditor scripts when needed', () => {
cy.get('script').then(nodes => {
numScripts = nodes.length;
});
cy.get('[data-test=adhoc_filters]').within(() => {
cy.get('.Select__control').scrollIntoView();
cy.get('.Select__control').click();
cy.get('input[type=text]').focus();
cy.focused().type('name{enter}');
cy.get("div[role='button']").first().click();
});
// antd tabs do lazy loading, so we need to click on tab with ace editor
cy.get('#filter-edit-popover').within(() => {
cy.get('.ant-tabs-tab').contains('Custom SQL').click();
cy.get('.ant-tabs-tab').contains('Simple').click();
});
cy.get('script').then(nodes => {
// should load new script chunks for SQL editor
expect(nodes.length).to.greaterThan(numScripts);
});
});
it('Set simple adhoc filter', () => {
cy.get('[aria-label="Comparator option"] .Select__control').click();
cy.get('[data-test=adhoc-filter-simple-value] input[type=text]').focus();
cy.focused().type('Jack{enter}', { delay: 20 });
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
cy.get(
'[data-test=adhoc_filters] .Select__control span.option-label',
).contains('name = Jack');
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@postJson',
chartSelector: 'svg',
});
});
it('Set custom adhoc filter', () => {
const filterType = 'name';
const filterContent = "'Amy' OR name = 'Donald'";
cy.get('[data-test=adhoc_filters] .Select__control').scrollIntoView();
cy.get('[data-test=adhoc_filters] .Select__control').click();
// remove previous input
cy.get('[data-test=adhoc_filters] input[type=text]').focus();
cy.focused().type('{backspace}');
cy.get('[data-test=adhoc_filters] input[type=text]').focus();
cy.focused().type(`${filterType}{enter}`);
cy.wait('@filterValues');
// selecting a new filter should auto-open the popup,
// so the tab should be visible by now
cy.get('#filter-edit-popover #adhoc-filter-edit-tabs-tab-SQL').click();
cy.get('#filter-edit-popover .ace_content').click();
cy.get('#filter-edit-popover .ace_text-input').type(filterContent);
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
// check if the filter was saved correctly
cy.get(
'[data-test=adhoc_filters] .Select__control span.option-label',
).contains(`${filterType} = ${filterContent}`);
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@postJson',
chartSelector: 'svg',
});
});
});
@@ -1,123 +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 { interceptChart } from 'cypress/utils';
describe('AdhocMetrics', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
it('Clear metric and set simple adhoc metric', () => {
const metric = 'sum(num_girls)';
const metricName = 'Sum Girls';
cy.get('[data-test=metrics]')
.find('[data-test="remove-control-button"]')
.click();
cy.get('[data-test=metrics]')
.contains('Drop columns/metrics here or click')
.click();
// Title edit for saved metrics is disabled - switch to Simple
cy.get('[id="adhoc-metric-edit-tabs-tab-SIMPLE"]').click();
cy.get('[data-test="AdhocMetricEditTitle#trigger"]').click();
cy.get('[data-test="AdhocMetricEditTitle#input"]').type(metricName);
cy.get('input[aria-label="Select column"]').click();
cy.get('input[aria-label="Select column"]').type('num_girls{enter}');
cy.get('input[aria-label="Select aggregate options"]').click();
cy.get('input[aria-label="Select aggregate options"]').type('sum{enter}');
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
cy.get('[data-test="control-label"]').contains(metricName);
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: `${metric} AS "${metricName}"`, // SQL statement
});
});
xit('Switch from simple to custom sql', () => {
cy.get('[data-test=metrics]')
.find('[data-test="metric-option"]')
.should('have.length', 1);
// select column "num"
cy.get('[data-test=metrics]').find('.Select__clear-indicator').click();
cy.get('[data-test=metrics]').find('.Select__control').click();
cy.get('[data-test=metrics]').find('.Select__control input').type('num');
cy.get('[data-test=metrics]')
.find('.option-label')
.first()
.should('have.text', 'num')
.click();
// add custom SQL
cy.get('#adhoc-metric-edit-tabs-tab-SQL').click();
cy.get('[data-test=metrics-edit-popover]').within(() => {
cy.get('.ace_content').click();
cy.get('.ace_text-input').type('/COUNT(DISTINCT name)', { force: true });
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
});
cy.get('button[data-test="run-query-button"]').click();
const metric = 'SUM(num)/COUNT(DISTINCT name)';
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: `${metric} AS "${metric}"`,
});
});
xit('Switch from custom sql tabs to simple', () => {
cy.get('[data-test=metrics]').within(() => {
cy.get('.Select__dropdown-indicator').click();
cy.get('input[type=text]').type('num_girls{enter}');
});
cy.get('[data-test=metrics]')
.find('[data-test="metric-option"]')
.should('have.length', 2);
cy.get('#metrics-edit-popover').within(() => {
cy.get('#adhoc-metric-edit-tabs-tab-SQL').click();
cy.get('.ace_identifier').contains('num_girls');
cy.get('.ace_content').click();
cy.get('.ace_text-input').type('{selectall}{backspace}SUM(num)');
cy.get('#adhoc-metric-edit-tabs-tab-SIMPLE').click();
cy.get('.Select__single-value').contains(/^num$/);
cy.get('button').contains('Save').click();
});
cy.get('button[data-test="run-query-button"]').click();
const metric = 'SUM(num)';
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: `${metric} AS "${metric}"`,
});
});
});
@@ -1,65 +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 { interceptV1ChartData } from './utils';
describe('Advanced analytics', () => {
beforeEach(() => {
interceptV1ChartData();
cy.intercept('PUT', '**/api/v1/explore/**').as('putExplore');
cy.intercept('GET', '**/explore/**').as('getExplore');
});
it('Create custom time compare', () => {
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@v1Data' });
cy.get('.ant-collapse-header')
.contains('Advanced analytics')
.click({ force: true });
cy.get('[data-test=time_compare]').find('.ant-select').click();
cy.get('[data-test=time_compare]')
.find('input[type=search]')
.type('28 days{enter}');
cy.get('[data-test=time_compare]').find('input[type=search]').clear();
cy.get('[data-test=time_compare]')
.find('input[type=search]')
.type('1 year{enter}');
cy.get('button[data-test="run-query-button"]').click();
cy.wait('@v1Data');
cy.wait('@putExplore');
cy.reload();
cy.verifySliceSuccess({
waitAlias: '@v1Data',
});
cy.wait('@getExplore');
cy.get('.ant-collapse-header')
.contains('Advanced analytics')
.click({ force: true });
cy.get('[data-test=time_compare]')
.find('.ant-select-selector')
.contains('28 days');
cy.get('[data-test=time_compare]')
.find('.ant-select-selector')
.contains('1 year');
});
});
@@ -1,48 +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 { interceptChart } from 'cypress/utils';
describe('Annotations', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
it('Create formula annotation y-axis goal line', () => {
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
const layerLabel = 'Goal line';
// get by text Annotations and Layers
cy.get('span').contains('Annotations and Layers').click();
cy.get('[data-test=annotation_layers]').click();
cy.get('[data-test="popover-content"]').within(() => {
cy.get('[aria-label=Name]').type(layerLabel);
cy.get('[aria-label=Formula]').type('y=1400000');
cy.get('button').contains('OK').click();
});
cy.get('button[data-test="run-query-button"]').click();
cy.get('[data-test=annotation_layers]').contains(layerLabel);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
});
@@ -1,192 +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.
*/
// ***********************************************
// Tests for links in the explore UI
// ***********************************************
import rison from 'rison';
import { nanoid } from 'nanoid';
import { interceptChart } from 'cypress/utils';
import { HEALTH_POP_FORM_DATA_DEFAULTS } from './visualizations/shared.helper';
const apiURL = (endpoint: string, queryObject: Record<string, unknown>) =>
`${endpoint}?q=${rison.encode(queryObject)}`;
describe('Test explore links', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
it('Open and close view query modal', () => {
cy.visitChartByName('Growth Rate');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[aria-label="Menu actions trigger"]').click();
cy.get('span').contains('View query').parent().click();
cy.wait('@chartData').then(() => {
cy.get('code');
});
cy.get('.ant-modal-content').within(() => {
cy.get('button.ant-modal-close').first().click({ force: true });
});
});
it('Test iframe link', () => {
cy.visitChartByName('Growth Rate');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[aria-label="Menu actions trigger"]').click();
cy.get('div[role="menuitem"]').within(() => {
cy.contains('Share').parent().click();
});
cy.getBySel('embed-code-button').click();
cy.get('#embed-code-popover').within(() => {
cy.get('textarea[name=embedCode]').contains('iframe');
});
});
it('Test chart save as AND overwrite', () => {
interceptChart({ legacy: false }).as('tableChartData');
const formData = {
...HEALTH_POP_FORM_DATA_DEFAULTS,
viz_type: 'table',
metrics: ['sum__SP_POP_TOTL'],
groupby: ['country_name'],
};
const newChartName = `Test chart [${nanoid()}]`;
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.url().then(() => {
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="saveas-radio"]').check();
cy.get('[data-test="new-chart-name"]').type(newChartName, {
force: true,
});
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.visitChartByName(newChartName);
// Overwriting!
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="save-overwrite-radio"]').check();
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
const query = {
filters: [
{
col: 'slice_name',
opr: 'eq',
value: newChartName,
},
],
};
cy.request(apiURL('/api/v1/chart/', query)).then(response => {
expect(response.body.count).equals(1);
});
cy.deleteChartByName(newChartName, true);
});
});
it('Test chart save as and add to new dashboard', () => {
const chartName = 'Growth Rate';
const newChartName = `${chartName} [${nanoid()}]`;
const dashboardTitle = `Test dashboard [${nanoid()}]`;
cy.visitChartByName(chartName);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="saveas-radio"]').check();
cy.get('[data-test="new-chart-name"]').click();
cy.get('[data-test="new-chart-name"]').clear();
cy.get('[data-test="new-chart-name"]').type(newChartName);
// Add a new option using the "CreatableSelect" feature
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
.find('input[aria-label="Select a dashboard"]')
.type(`${dashboardTitle}`, { force: true });
cy.get(`.ant-select-item[title="${dashboardTitle}"]`).click({
force: true,
});
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@chartData' });
let query = {
filters: [
{
col: 'dashboard_title',
opr: 'eq',
value: dashboardTitle,
},
],
};
cy.request(apiURL('/api/v1/dashboard/', query)).then(response => {
expect(response.body.count).equals(1);
});
cy.visitChartByName(newChartName);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="save-overwrite-radio"]').check();
cy.get('[data-test="new-chart-name"]').click();
cy.get('[data-test="new-chart-name"]').clear();
cy.get('[data-test="new-chart-name"]').type(newChartName);
// This time around, typing the same dashboard name
// will select the existing one
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
.find('input[aria-label^="Select a dashboard"]')
.type(`${dashboardTitle}{enter}`, { force: true });
cy.get(`.ant-select-item[title="${dashboardTitle}"]`).click({
force: true,
});
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@chartData' });
query = {
filters: [
{
col: 'slice_name',
opr: 'eq',
value: chartName,
},
],
};
cy.request(apiURL('/api/v1/chart/', query)).then(response => {
expect(response.body.count).equals(1);
});
query = {
filters: [
{
col: 'dashboard_title',
opr: 'eq',
value: dashboardTitle,
},
],
};
cy.request(apiURL('/api/v1/dashboard/', query)).then(response => {
expect(response.body.count).equals(1);
});
cy.deleteDashboardByName(dashboardTitle, true);
});
});
@@ -1,80 +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 { interceptChart } from 'cypress/utils';
describe('Visualization > Big Number with Trendline', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
const BIG_NUMBER_FORM_DATA = {
datasource: '2__table',
viz_type: 'big_number',
slice_id: 42,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '2000 : 2014-01-02',
metric: 'sum__SP_POP_TOTL',
adhoc_filters: [],
compare_lag: '10',
compare_suffix: 'over 10Y',
y_axis_format: '.3s',
show_trend_line: true,
start_y_axis_at_zero: true,
color_picker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@chartData',
chartSelector: '.superset-legacy-chart-big-number',
});
}
it('should work', () => {
verify(BIG_NUMBER_FORM_DATA);
cy.get('.chart-container .header-line');
cy.get('.chart-container canvas');
});
it('should work without subheader', () => {
verify({
...BIG_NUMBER_FORM_DATA,
compare_lag: null,
});
cy.get('.chart-container .header-line');
cy.get('.chart-container .subtitle-line').should('not.exist');
cy.get('.chart-container canvas');
});
it('should not render trendline when hidden', () => {
verify({
...BIG_NUMBER_FORM_DATA,
show_trend_line: false,
});
cy.get('[data-test="chart-container"] .header-line');
cy.get('[data-test="chart-container"] canvas').should('not.exist');
});
});
@@ -1,79 +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 { interceptChart } from 'cypress/utils';
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
describe('Visualization > Big Number Total', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
const BIG_NUMBER_DEFAULTS = {
...FORM_DATA_DEFAULTS,
viz_type: 'big_number_total',
};
it('Test big number chart with adhoc metric', () => {
const formData = { ...BIG_NUMBER_DEFAULTS, metric: NUM_METRIC };
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: NUM_METRIC.label,
});
});
it('Test big number chart with simple filter', () => {
const filters = [
{
expressionType: 'SIMPLE',
subject: 'name',
operator: 'IN',
comparator: ['Aaron', 'Amy', 'Andrea'],
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_4y6teao56zs_ebjsvwy48c',
},
];
const formData = {
...BIG_NUMBER_DEFAULTS,
metric: 'count',
adhoc_filters: filters,
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
it('Test big number chart ignores groupby', () => {
const formData = {
...BIG_NUMBER_DEFAULTS,
metric: NUM_METRIC,
groupby: ['state'],
};
cy.visitChartByParams(formData);
cy.wait(['@chartData']).then(async ({ response }) => {
cy.verifySliceContainer();
const responseBody = response?.body;
expect(responseBody.result[0].query).not.contains(formData.groupby[0]);
});
});
});
@@ -1,65 +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 { getDatasetId } from './shared.helper';
describe('Visualization > Box Plot', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
});
const getBoxPlotFormData = datasetId => ({
datasource: `${datasetId}__table`,
viz_type: 'box_plot',
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '1960-01-01 : now',
metrics: ['sum__SP_POP_TOTL'],
adhoc_filters: [],
groupby: ['region'],
limit: '25',
color_scheme: 'bnbColors',
whisker_options: 'Min/max (no outliers)',
});
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
it('should work', () => {
getDatasetId('wb_health_population').then(datasetId => {
verify(getBoxPlotFormData(datasetId));
cy.get('.chart-container .box_plot canvas').should('have.length', 1);
});
});
it('should allow type to search color schemes', () => {
getDatasetId('wb_health_population').then(datasetId => {
verify(getBoxPlotFormData(datasetId));
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
});
});
});
@@ -1,108 +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 { getDatasetId } from './shared.helper';
describe('Visualization > Bubble', () => {
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
const getBubbleFormData = datasetId => ({
datasource: `${datasetId}__table`,
viz_type: 'bubble',
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '2011-01-01 : 2011-01-02',
series: 'region',
entity: 'country_name',
x: 'sum__SP_RUR_TOTL_ZS',
y: 'sum__SP_DYN_LE00_IN',
size: 'sum__SP_POP_TOTL',
max_bubble_size: '50',
limit: 0,
color_scheme: 'bnbColors',
show_legend: true,
x_axis_label: '',
left_margin: 'auto',
x_axis_format: '.3s',
x_ticks_layout: 'auto',
x_log_scale: false,
x_axis_showminmax: false,
y_axis_label: '',
bottom_margin: 'auto',
y_axis_format: '.3s',
y_log_scale: false,
y_axis_showminmax: false,
});
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
it('should work with filter', () => {
getDatasetId('wb_health_population').then(datasetId => {
verify({
...getBubbleFormData(datasetId),
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: '==',
comparator: 'South Asia',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_b2tfg1rs8y_8kmrcyxvsqd',
},
],
});
cy.get('[data-test="chart-container"]').should('be.visible');
cy.get('[data-test="chart-container"]').within(() => {
cy.get('svg').find('.nv-point-clips circle').should('have.length', 8);
});
cy.get('[data-test="chart-container"]').then(nodeList => {
// Check that all circles have same color.
const color = nodeList[0].getAttribute('fill');
const circles = Array.prototype.slice.call(nodeList);
expect(circles.every(c => c.getAttribute('fill') === color)).to.equal(
true,
);
});
});
});
it('should allow type to search color schemes and apply the scheme', () => {
getDatasetId('wb_health_population').then(datasetId => {
cy.visitChartByParams(getBubbleFormData(datasetId));
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
cy.get('[data-test=run-query-button]').click();
cy.get('.bubble .nv-legend .nv-legend-symbol').should(
'have.css',
'fill',
'rgb(31, 168, 201)',
);
});
});
});
@@ -1,100 +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.
*/
describe('Visualization > Compare', () => {
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
const COMPARE_FORM_DATA = {
datasource: '3__table',
viz_type: 'compare',
slice_id: 60,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100 years ago : now',
metrics: ['count'],
adhoc_filters: [],
groupby: [],
order_desc: true,
contribution: false,
row_limit: 50000,
color_scheme: 'bnbColors',
x_axis_label: 'Frequency',
bottom_margin: 'auto',
x_ticks_layout: 'auto',
x_axis_format: 'smart_date',
x_axis_showminmax: false,
y_axis_label: 'Num',
left_margin: 'auto',
y_axis_showminmax: false,
y_log_scale: false,
y_axis_format: '.3s',
rolling_type: 'None',
comparison_type: 'values',
annotation_layers: [],
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
it('should work without groupby', () => {
verify(COMPARE_FORM_DATA);
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 1);
});
it('should with group by', () => {
verify({
...COMPARE_FORM_DATA,
groupby: ['gender'],
});
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 2);
});
it('should work with filter', () => {
verify({
...COMPARE_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'gender',
operator: '==',
comparator: 'boy',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
},
],
});
cy.get('.chart-container .nvd3 path.nv-line').should('have.length', 1);
});
it('should allow type to search color schemes and apply the scheme', () => {
verify(COMPARE_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
});
});
@@ -1,54 +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 { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
describe('Download Chart > Bar chart', () => {
const VIZ_DEFAULTS = {
...FORM_DATA_DEFAULTS,
viz_type: 'echarts_timeseries_bar',
};
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
it('download chart with image works', () => {
const formData = {
...VIZ_DEFAULTS,
metrics: NUM_METRIC,
groupby: ['state'],
};
cy.visitChartByParams(formData);
cy.get('.header-with-actions .ant-dropdown-trigger').click();
cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click();
cy.get(
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(1) > .ant-dropdown-menu-submenu-title',
).click();
cy.get(
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)',
).click();
cy.verifyDownload('.jpg', {
contains: true,
timeout: 25000,
interval: 600,
});
});
});
@@ -1,75 +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.
*/
describe('Visualization > Gauge', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
});
const GAUGE_FORM_DATA = {
datasource: '3__table',
viz_type: 'gauge_chart',
metric: 'count',
adhoc_filters: [],
slice_id: 54,
row_limit: 10,
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
it('should work', () => {
verify(GAUGE_FORM_DATA);
cy.get('.chart-container .gauge_chart canvas').should('have.length', 1);
});
it('should work with simple filter', () => {
verify({
...GAUGE_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'country_code',
operator: '==',
comparator: 'USA',
clause: 'WHERE',
sqlExpression: null,
isExtra: false,
isNew: false,
filterOptionName: 'filter_jaemvkxd5h_ku22m3wyo',
},
],
});
cy.get('.chart-container .gauge_chart canvas').should('have.length', 1);
});
it('should allow type to search color schemes', () => {
verify(GAUGE_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('bnbColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
).should('exist');
});
});
@@ -1,91 +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.
*/
type adhocFilter = {
expressionType: string;
subject: string;
operator: string;
comparator: string;
clause: string;
sqlExpression: string | null;
filterOptionName: string;
};
describe('Visualization > Graph', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
});
const GRAPH_FORM_DATA = {
datasource: '1__table',
viz_type: 'graph_chart',
slice_id: 55,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100 years ago : now',
metric: 'sum__value',
adhoc_filters: [],
source: 'source',
target: 'target',
row_limit: 50000,
show_legend: true,
color_scheme: 'bnbColors',
};
function verify(formData: {
[name: string]: string | boolean | number | Array<adhocFilter>;
}): void {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
it('should work with ad-hoc metric', () => {
verify(GRAPH_FORM_DATA);
cy.get('.chart-container .graph_chart canvas').should('have.length', 1);
});
it('should work with simple filter', () => {
verify({
...GRAPH_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'source',
operator: '==',
comparator: 'Agriculture',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
},
],
});
cy.get('.chart-container .graph_chart canvas').should('have.length', 1);
});
it('should allow type to search color schemes', () => {
verify(GRAPH_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('bnbColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
).should('exist');
});
});
@@ -1,82 +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.
*/
describe('Visualization > Pie', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data*').as('getJson');
});
const PIE_FORM_DATA = {
datasource: '3__table',
viz_type: 'pie',
slice_id: 55,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100 years ago : now',
metric: 'sum__num',
adhoc_filters: [],
groupby: ['gender'],
row_limit: 50000,
pie_label_type: 'key',
donut: false,
show_legend: true,
show_labels: true,
labels_outside: true,
color_scheme: 'bnbColors',
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
it('should work with ad-hoc metric', () => {
verify(PIE_FORM_DATA);
cy.get('.chart-container .pie canvas').should('have.length', 1);
});
it('should work with simple filter', () => {
verify({
...PIE_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'gender',
operator: '==',
comparator: 'boy',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_tqx1en70hh_7nksse7nqic',
},
],
});
cy.get('.chart-container .pie canvas').should('have.length', 1);
});
it('should allow type to search color schemes', () => {
verify(PIE_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
});
});
@@ -1,106 +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.
*/
describe('Visualization > Pivot Table', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data**').as('chartData');
});
const PIVOT_TABLE_FORM_DATA = {
datasource: '3__table',
viz_type: 'pivot_table_v2',
slice_id: 61,
granularity_sqla: 'ds',
time_grain_sqla: 'P1D',
time_range: '100 years ago : now',
metrics: ['sum__num'],
adhoc_filters: [],
groupbyRows: ['name'],
groupbyColumns: ['state'],
series_limit: 5000,
aggregateFunction: 'Sum',
rowTotals: true,
colTotals: true,
valueFormat: '.3s',
combineMetric: false,
};
const TEST_METRIC = {
expressionType: 'SIMPLE',
column: {
id: 338,
column_name: 'num_boys',
expression: '',
filterable: false,
groupby: false,
is_dttm: false,
type: 'BIGINT',
optionName: '_col_num_boys',
},
aggregate: 'SUM',
hasCustomLabel: false,
label: 'SUM(num_boys)',
optionName: 'metric_gvpdjt0v2qf_6hkf56o012',
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
}
it('should work with single groupby', () => {
verify(PIVOT_TABLE_FORM_DATA);
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
});
it('should work with more than one groupby', () => {
verify({
...PIVOT_TABLE_FORM_DATA,
groupbyRows: ['name', 'gender'],
});
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
cy.get('.chart-container tr:eq(2) th:eq(1)').contains('gender');
});
it('should work with multiple metrics', () => {
verify({
...PIVOT_TABLE_FORM_DATA,
metrics: ['sum__num', TEST_METRIC],
});
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
cy.get('.chart-container tr:eq(0) th:eq(3)').contains('SUM(num_boys)');
cy.get('.chart-container tr:eq(1) th:eq(0)').contains('state');
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
});
it('should work with multiple groupby and multiple metrics', () => {
verify({
...PIVOT_TABLE_FORM_DATA,
groupbyRows: ['name', 'gender'],
metrics: ['sum__num', TEST_METRIC],
});
cy.get('.chart-container tr:eq(0) th:eq(2)').contains('sum__num');
cy.get('.chart-container tr:eq(0) th:eq(3)').contains('SUM(num_boys)');
cy.get('.chart-container tr:eq(2) th:eq(0)').contains('name');
cy.get('.chart-container tr:eq(2) th:eq(1)').contains('gender');
});
});
@@ -1,97 +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.
*/
describe('Visualization > Sunburst', () => {
beforeEach(() => {
cy.intercept('POST', '**/api/v1/chart/data**').as('chartData');
});
const SUNBURST_FORM_DATA = {
datasource: '2__table',
viz_type: 'sunburst_v2',
slice_id: 47,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: 'No filter',
columns: ['region'],
metric: 'sum__SP_POP_TOTL',
adhoc_filters: [],
row_limit: 50000,
color_scheme: 'bnbColors',
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData' });
}
// requires the ability to render charts using SVG only for tests
it.skip('should work without secondary metric', () => {
verify(SUNBURST_FORM_DATA);
cy.get('.chart-container svg g path').should('have.length', 7);
});
// requires the ability to render charts using SVG only for tests
it.skip('should work with secondary metric', () => {
verify({
...SUNBURST_FORM_DATA,
secondary_metric: 'sum__SP_RUR_TOTL',
});
cy.get('.chart-container svg g path').should('have.length', 7);
});
// requires the ability to render charts using SVG only for tests
it.skip('should work with multiple columns', () => {
verify({
...SUNBURST_FORM_DATA,
columns: ['region', 'country_name'],
});
cy.get('.chart-container svg g path').should('have.length', 221);
});
// requires the ability to render charts using SVG only for tests
it.skip('should work with filter', () => {
verify({
...SUNBURST_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: 'IN',
comparator: ['South Asia', 'North America'],
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_txje2ikiv6_wxmn0qwd1xo',
},
],
});
cy.get('.chart-container svg g path').should('have.length', 2);
});
it('should allow type to search color schemes', () => {
verify(SUNBURST_FORM_DATA);
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('supersetColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
});
});
@@ -1,474 +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 { interceptChart } from 'cypress/utils';
import {
FORM_DATA_DEFAULTS,
NUM_METRIC,
MAX_DS,
MAX_STATE,
SIMPLE_FILTER,
} from './shared.helper';
// Table
describe('Visualization > Table', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
});
const VIZ_DEFAULTS = {
...FORM_DATA_DEFAULTS,
viz_type: 'table',
row_limit: 1000,
};
const PERCENT_METRIC = {
expressionType: 'SQL',
sqlExpression: 'CAST(SUM(num_girls) AS FLOAT)/SUM(num)',
column: null,
aggregate: null,
hasCustomLabel: true,
label: 'Girls',
optionName: 'metric_6qwzgc8bh2v_zox7hil1mzs',
};
it('Use default time column', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
granularity_sqla: undefined,
metrics: ['count'],
});
cy.get('[data-test=adhoc_filters]').contains('ds');
});
it('Format non-numeric metrics correctly', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P3M',
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
});
// when format with smart_date, time column use format by granularity
cy.get('.chart-container td:nth-child(1)').contains('2008 Q1');
// other column with timestamp use adaptive formatting
cy.get('.chart-container td:nth-child(3)').contains('2008');
cy.get('.chart-container td:nth-child(4)').contains('TX');
});
it('Format with table_timestamp_format', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P3M',
table_timestamp_format: '%Y-%m-%d %H:%M',
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
});
// time column and MAX(ds) metric column both use UTC time
cy.get('.chart-container td:nth-child(1)').contains('2008-01-01 00:00');
cy.get('.chart-container td:nth-child(3)').contains('2008-01-01 00:00');
cy.get('.chart-container td')
.contains('2008-01-01 08:00')
.should('not.exist');
// time column should not use time granularity when timestamp format is set
cy.get('.chart-container td').contains('2008 Q1').should('not.exist');
// other num numeric metric column should stay as string
cy.get('.chart-container td').contains('TX');
});
it('Test table with groupby', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: [NUM_METRIC, MAX_DS],
groupby: ['name'],
});
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: /GROUP BY.*name/i,
chartSelector: 'table',
});
});
it('Test table with groupby + time column', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P3M',
metrics: [NUM_METRIC, MAX_DS],
groupby: ['name'],
});
cy.wait('@chartData').then(({ response }) => {
cy.verifySliceContainer('table');
const records = response?.body.result[0].data;
// should sort by first metric when no sort by metric is set
expect(records[0][NUM_METRIC.label]).greaterThan(
records[1][NUM_METRIC.label],
);
});
// should handle frontend sorting correctly
cy.get('.chart-container th').contains('name').click();
cy.get('.chart-container td:nth-child(2):eq(0)').contains('Adam');
cy.get('.chart-container th').contains('ds').click();
cy.get('.chart-container th').contains('ds').click();
cy.get('.chart-container td:nth-child(1):eq(0)').contains('2008');
});
it('Test table with percent metrics and groupby', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
percent_metrics: PERCENT_METRIC,
metrics: [],
groupby: ['name'],
});
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
it('Test table with groupby order desc', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: NUM_METRIC,
groupby: ['name'],
order_desc: true,
});
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
it('Test table with groupby + order by + no metric', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: [],
groupby: ['name'],
timeseries_limit_metric: NUM_METRIC,
order_desc: true,
});
// should contain only the group by column
cy.get('.chart-container th').its('length').should('eq', 1);
// should order correctly
cy.get('.chart-container td:eq(0)').contains('Michael');
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
it('Test table with groupby and limit', () => {
const limit = 10;
const formData = {
...VIZ_DEFAULTS,
metrics: NUM_METRIC,
groupby: ['name'],
row_limit: limit,
};
cy.visitChartByParams(formData);
cy.wait('@chartData').then(({ response }) => {
cy.verifySliceContainer('table');
expect(response?.body.result[0].data.length).to.eq(limit);
});
cy.get('[data-test="row-count-label"]').contains('10 rows');
});
it('Test table with columns and row limit', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
// should still work when query_mode is not-set/invalid
query_mode: undefined,
all_columns: ['state'],
metrics: [],
row_limit: 100,
});
// should display in raw records mode
cy.get(
'div[data-test="query_mode"] .ant-radio-button-wrapper-checked',
).contains('Raw records');
cy.get('div[data-test="all_columns"]').should('be.visible');
cy.get('div[data-test="groupby"]').should('not.exist');
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
cy.get('[data-test="row-count-label"]').contains('100 rows');
// should allow switch back to aggregate mode
cy.get('div[data-test="query_mode"] .ant-radio-button-wrapper')
.contains('Aggregate')
.click();
cy.get(
'div[data-test="query_mode"] .ant-radio-button-wrapper-checked',
).contains('Aggregate');
cy.get('div[data-test="all_columns"]').should('not.exist');
cy.get('div[data-test="groupby"]').should('be.visible');
});
it('Test table with columns, ordering, and row limit', () => {
const limit = 10;
const formData = {
...VIZ_DEFAULTS,
query_mode: 'raw',
all_columns: ['name', 'state', 'ds', 'num'],
metrics: [],
row_limit: limit,
order_by_cols: ['["num", false]'],
};
cy.visitChartByParams(formData);
cy.wait('@chartData').then(({ response }) => {
cy.verifySliceContainer('table');
const records = response?.body.result[0].data;
expect(records[0].num).greaterThan(records[records.length - 1].num);
});
});
it('Test table with simple filter', () => {
const metrics = ['count'];
const filters = [SIMPLE_FILTER];
const formData = { ...VIZ_DEFAULTS, metrics, adhoc_filters: filters };
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'table' });
});
it('Tests table number formatting with % in metric name', () => {
const formData = {
...VIZ_DEFAULTS,
percent_metrics: PERCENT_METRIC,
groupby: ['state'],
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@chartData',
querySubstring: /GROUP BY.*state/i,
chartSelector: 'table',
});
cy.get('td').contains(/\d*%/);
});
it('Test row limit with server pagination toggle', () => {
const serverPaginationSelector =
'[data-test="server_pagination-header"] div.pull-left [type="checkbox"]';
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
row_limit: 100,
});
// Enable server pagination
cy.get(serverPaginationSelector).click();
// Click row limit control and select high value (200k)
cy.get('div[aria-label="Row limit"]').click();
// Type 200000 and press enter to select the option
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('200000{enter}');
// Verify that there is no error tooltip when server pagination is enabled
cy.get('[data-test="error-tooltip"]').should('not.exist');
// Disable server pagination
cy.get(serverPaginationSelector).click();
// Verify error tooltip appears
cy.get('[data-test="error-tooltip"]').should('be.visible');
// Trigger mouseover and verify tooltip text
cy.get('[data-test="error-tooltip"]').trigger('mouseover');
// Verify tooltip content
cy.get('.ant-tooltip-inner').should('be.visible');
cy.get('.ant-tooltip-inner').should(
'contain',
'Server pagination needs to be enabled for values over',
);
// Hide the tooltip by adding display:none style
cy.get('.ant-tooltip').invoke('attr', 'style', 'display: none');
// Enable server pagination again
cy.get(serverPaginationSelector).click();
cy.get('[data-test="error-tooltip"]').should('not.exist');
cy.get('div[aria-label="Row limit"]').click();
// Type 1000000
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('1000000');
// Wait for 1 second
cy.wait(1000);
// Press enter
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('{enter}');
// Wait for error tooltip to appear and verify its content
cy.get('[data-test="error-tooltip"]')
.should('be.visible')
.trigger('mouseover');
// Wait for tooltip content and verify
cy.get('.ant-tooltip-inner').should('exist');
cy.get('.ant-tooltip-inner').should('be.visible');
// Verify tooltip content separately
cy.get('.ant-tooltip-inner').should('contain', 'Value cannot exceed');
});
it('Test sorting with server pagination enabled', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
groupby: ['name'],
row_limit: 100000,
server_pagination: true, // Enable server pagination
});
// Wait for the initial data load
cy.wait('@chartData');
// Get the first column header (name)
cy.get('.chart-container th').contains('name').as('nameHeader');
// Click to sort ascending
cy.get('@nameHeader').click();
cy.wait('@chartData');
// Verify first row starts with 'A'
cy.get('.chart-container td:first').invoke('text').should('match', /^[Aa]/);
// Click again to sort descending
cy.get('@nameHeader').click();
cy.wait('@chartData');
// Verify first row starts with 'Z'
cy.get('.chart-container td:first').invoke('text').should('match', /^[Zz]/);
// Test numeric sorting
cy.get('.chart-container th').contains('COUNT').as('countHeader');
// Click to sort ascending by count
cy.get('@countHeader').click();
cy.wait('@chartData');
// Get first two count values and verify ascending order
cy.get('.chart-container td:nth-child(2)').then($cells => {
const first = parseFloat($cells[0].textContent || '0');
const second = parseFloat($cells[1].textContent || '0');
expect(first).to.be.at.most(second);
});
// Click again to sort descending
cy.get('@countHeader').click();
cy.wait('@chartData');
// Get first two count values and verify descending order
cy.get('.chart-container td:nth-child(2)').then($cells => {
const first = parseFloat($cells[0].textContent || '0');
const second = parseFloat($cells[1].textContent || '0');
expect(first).to.be.at.least(second);
});
});
it('Test search with server pagination enabled', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
groupby: ['name', 'state'],
row_limit: 100000,
server_pagination: true,
include_search: true,
});
cy.wait('@chartData');
const searchInputSelector = '.dt-global-filter input';
// Basic search test
cy.get(searchInputSelector).should('be.visible');
cy.get(searchInputSelector).type('John');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/John/i);
});
// Clear and test case-insensitive search
cy.get(searchInputSelector).clear();
cy.wait('@chartData');
cy.get(searchInputSelector).type('mary');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/Mary/i);
});
// Test special characters
cy.get(searchInputSelector).clear();
cy.get(searchInputSelector).type('Nicole');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/Nicole/i);
});
// Test no results
cy.get(searchInputSelector).clear();
cy.get(searchInputSelector).type('XYZ123');
cy.wait('@chartData');
cy.get('.chart-container').contains('No records found');
// Test column-specific search
cy.get('.search-select').should('be.visible');
cy.get('.search-select').click();
cy.get('.ant-select-dropdown').should('be.visible');
cy.get('.ant-select-item-option').contains('state').should('be.visible');
cy.get('.ant-select-item-option').contains('state').click();
cy.get(searchInputSelector).clear();
cy.get(searchInputSelector).type('CA');
cy.wait('@chartData');
cy.wait(1000);
cy.get('td[aria-labelledby="header-state"]').should('be.visible');
cy.get('td[aria-labelledby="header-state"]')
.first()
.should('contain', 'CA');
});
});
@@ -1,130 +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 { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
describe('Visualization > Time TableViz', () => {
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
const VIZ_DEFAULTS = { ...FORM_DATA_DEFAULTS, viz_type: 'time_table' };
it('Test time series table multiple metrics last year total', () => {
const formData = {
...VIZ_DEFAULTS,
metrics: [NUM_METRIC, 'count'],
column_collection: [
{
key: '9g4K-B-YL',
label: 'Last Year',
colType: 'time',
timeLag: '1',
comparisonType: 'value',
},
],
url: '',
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
});
cy.get('[data-test="time-table"]').within(() => {
cy.get('span').contains('Sum(num)');
cy.get('span').contains('COUNT(*)');
});
});
it('Test time series table metric and group by last year total', () => {
const formData = {
...VIZ_DEFAULTS,
metrics: [NUM_METRIC],
groupby: ['gender'],
column_collection: [
{
key: '9g4K-B-YL',
label: 'Last Year',
colType: 'time',
timeLag: '1',
comparisonType: 'value',
},
],
url: '',
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
});
cy.get('[data-test="time-table"]').within(() => {
cy.get('td').contains('boy');
cy.get('td').contains('girl');
});
});
it('Test time series various time columns', () => {
const formData = {
...VIZ_DEFAULTS,
metrics: [NUM_METRIC, 'count'],
column_collection: [
{ key: 'LHHNPhamU', label: 'Current', colType: 'time', timeLag: 0 },
{
key: '9g4K-B-YL',
label: 'Last Year',
colType: 'time',
timeLag: '1',
comparisonType: 'value',
},
{
key: 'JVZXtNu7_',
label: 'YoY',
colType: 'time',
timeLag: 1,
comparisonType: 'perc',
d3format: '%',
},
{ key: 'tN5Gba36u', label: 'Trend', colType: 'spark' },
],
url: '',
};
cy.visitChartByParams(formData);
cy.verifySliceSuccess({
waitAlias: '@getJson',
querySubstring: NUM_METRIC.label,
});
cy.get('[data-test="time-table"]').within(() => {
cy.get('th').contains('Current');
cy.get('th').contains('Last Year');
cy.get('th').contains('YoY');
cy.get('th').contains('Trend');
cy.get('span').contains('%');
cy.get('svg')
.first()
.then(charts => {
const firstChart = charts[0];
expect(firstChart.clientWidth).greaterThan(0);
expect(firstChart.clientHeight).greaterThan(0);
});
});
});
});
@@ -1,95 +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.
*/
describe('Visualization > World Map', () => {
beforeEach(() => {
cy.intercept('POST', '**/superset/explore_json/**').as('getJson');
});
const WORLD_MAP_FORM_DATA = {
datasource: '2__table',
viz_type: 'world_map',
slice_id: 45,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '2014-01-01 : 2014-01-02',
entity: 'country_code',
country_fieldtype: 'cca3',
metric: 'sum__SP_RUR_TOTL_ZS',
adhoc_filters: [],
row_limit: 50000,
show_bubbles: true,
secondary_metric: 'sum__SP_POP_TOTL',
max_bubble_size: '25',
};
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
}
it('should work with ad-hoc metric', () => {
verify(WORLD_MAP_FORM_DATA);
cy.get('.bubbles circle.datamaps-bubble').should('have.length', 206);
});
it('should work with simple filter', () => {
verify({
...WORLD_MAP_FORM_DATA,
metric: 'count',
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: '==',
comparator: 'South Asia',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_8aqxcf5co1a_x7lm2d1fq0l',
},
],
});
cy.get('.bubbles circle.datamaps-bubble').should('have.length', 8);
});
it('should hide bubbles when told so', () => {
verify({
...WORLD_MAP_FORM_DATA,
show_bubbles: false,
});
cy.get('.slice_container').then(containers => {
expect(
containers[0].querySelectorAll('.bubbles circle.datamaps-bubble')
.length,
).to.equal(0);
});
});
it('should allow type to search color schemes', () => {
verify(WORLD_MAP_FORM_DATA);
cy.get('.Control[data-test="linear_color_scheme"]').scrollIntoView();
cy.get(
'.Control[data-test="linear_color_scheme"] input[type="search"]',
).focus();
cy.focused().type('greens{enter}');
cy.get(
'.Control[data-test="linear_color_scheme"] .ant-select-selection-item [data-test="greens"]',
).should('exist');
});
});
+6 -6
View File
@@ -8020,9 +8020,9 @@
"peer": true
},
"node_modules/tmp": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"engines": {
"node": ">=14.14"
}
@@ -14601,9 +14601,9 @@
"peer": true
},
"tmp": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ=="
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="
},
"to-regex-range": {
"version": "5.0.1",
+10 -3
View File
@@ -39,7 +39,7 @@
// oxlint versions (not actually enforced). Documented here for future
// maintainers if/when oxlint adds them, re-enable in the relevant
// plugin section above.
// import: newline-after-import, no-extraneous-dependencies,
// import: no-extraneous-dependencies,
// no-import-module-exports, no-relative-packages,
// no-unresolved, no-useless-path-segments
// react: default-props-match-prop-types, destructuring-assignment,
@@ -47,7 +47,6 @@
// forbid-prop-types, function-component-definition,
// jsx-no-bind, jsx-uses-vars, no-access-state-in-setstate,
// no-deprecated, no-did-update-set-state, no-typos,
// no-unstable-nested-components,
// no-unused-class-component-methods, no-unused-prop-types,
// no-unused-state, prefer-stateless-function, prop-types,
// require-default-props, sort-comp, static-property-placement
@@ -137,6 +136,7 @@
"import/no-self-import": "error",
"import/no-cycle": "off",
"import/prefer-default-export": "off",
"import/newline-after-import": "error",
// === React plugin rules ===
"react/jsx-filename-extension": [
@@ -184,6 +184,10 @@
"error",
{ "button": true, "submit": true, "reset": false }
],
// TODO: Graduate to "error" after cleanup pass ~150 violations
// across the codebase require hoisting nested component definitions
// out of their parent render functions.
"react/no-unstable-nested-components": "warn",
// === React Hooks rules ===
// TODO: Fix conditional hook usage and anonymous component issues
@@ -271,7 +275,10 @@
},
"overrides": [
{
"files": ["plugins/plugin-chart-table/src/TableChart.tsx", "plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"],
"files": [
"plugins/plugin-chart-table/src/TableChart.tsx",
"plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"
],
"rules": {
"jsx-a11y/no-redundant-roles": "off"
}
+541 -600
View File
File diff suppressed because it is too large Load Diff
+14 -12
View File
@@ -43,6 +43,8 @@
"build-instrumented": "cross-env NODE_ENV=production BABEL_ENV=instrumented webpack --mode=production --color",
"build-storybook": "storybook build",
"build-translation": "scripts/po2json.sh",
"translations:build-index": "python3 ../scripts/translations/build_translation_index.py",
"translations:backfill": "python3 ../scripts/translations/backfill_po.py",
"bundle-stats": "cross-env BUNDLE_ANALYZER=true npm run build && npx open-cli ../superset/static/stats/statistics.html",
"clear-npm": "mkdir -p /tmp/empty && rsync -a --delete /tmp/empty/ node_modules/ && rmdir node_modules /tmp/empty",
"core:cover": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage --coverageThreshold='{\"global\":{\"statements\":100,\"branches\":100,\"functions\":100,\"lines\":100}}' --collectCoverageFrom='[\"packages/**/src/**/*.{js,ts}\", \"!packages/superset-core/**/*\"]' packages",
@@ -112,7 +114,7 @@
"@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@googleapis/sheets": "^13.0.1",
"@googleapis/sheets": "^13.0.2",
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
@@ -177,7 +179,7 @@
"fs-extra": "^11.3.5",
"fuse.js": "^7.3.0",
"geolib": "^3.3.14",
"geostyler": "^18.5.1",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
@@ -191,7 +193,7 @@
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.24.0",
"markdown-to-jsx": "^9.8.0",
"markdown-to-jsx": "^9.8.1",
"match-sorter": "^8.3.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -202,7 +204,7 @@
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^18.2.0",
"react-arborist": "^3.6.1",
"react-arborist": "^3.7.0",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
@@ -275,8 +277,8 @@
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.33",
"@swc/plugin-emotion": "^14.9.0",
"@swc/core": "^1.15.40",
"@swc/plugin-emotion": "^14.10.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -310,7 +312,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.29",
"baseline-browser-mapping": "^2.10.32",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -330,7 +332,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -350,7 +352,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.65.0",
"oxlint": "^1.66.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
@@ -365,19 +367,19 @@
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.0",
"ts-jest": "^29.4.10",
"ts-jest": "^29.4.11",
"tscw-config": "^1.1.2",
"tsx": "^4.22.3",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.10",
"webpack": "^5.106.2",
"webpack": "^5.107.1",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.4.1",
"webpack-sources": "^3.5.0",
"webpack-visualizer-plugin2": "^2.0.0"
},
"peerDependencies": {
@@ -56,7 +56,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -48,6 +48,7 @@ import NoResultsComponent from './NoResultsComponent';
import { isMatrixifyEnabled } from '../types/matrixify';
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
import { supersetTheme, SupersetTheme } from '@apache-superset/core/theme';
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
export type WrapperProps = Dimension & {
@@ -897,6 +897,476 @@ test('fires onChange when pasting a selection', async () => {
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
});
test('replaces cached options with search results instead of merging', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const searchData = [{ label: 'Search Match', value: 100 }];
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: searchData, totalCount: 1 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
let options = await findAllSelectOptions();
expect(options).toHaveLength(10);
await type('search');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Search Match');
});
test('shows all options when filterOption is false', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Base ${i}`,
value: i,
}));
const searchData = Array.from({ length: 5 }, (_, i) => ({
label: `Server ${i}`,
value: 100 + i,
}));
const loadOptions = jest.fn(async (search: string) =>
search === ''
? { data: page0Data, totalCount: 100 }
: { data: searchData, totalCount: 5 },
);
render(
<AsyncSelect
{...defaultProps}
options={loadOptions}
filterOption={false}
/>,
);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('zzz_no_match');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
const options = await findAllSelectOptions();
expect(options).toHaveLength(5);
expect(options[0]).toHaveTextContent('Server 0');
});
test('preserves new option entry across search fetch when allowNewOptions is on', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: [], totalCount: 0 };
});
render(
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('newval');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('newval');
// Stale page-0 options must not bleed through.
expect(screen.queryByText('Option 0')).not.toBeInTheDocument();
});
test('restores base options when search is cleared', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const searchData = [{ label: 'Search Match', value: 100 }];
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: searchData, totalCount: 1 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('search');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
let options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Search Match');
// type() clears the input before typing, so passing '' clears the search.
await type('');
await waitFor(async () => {
options = await findAllSelectOptions();
expect(options).toHaveLength(10);
});
expect(options[0]).toHaveTextContent('Option 0');
expect(screen.queryByText('Search Match')).not.toBeInTheDocument();
});
test('replaces results when switching between two searches', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return {
data: [{ label: `Match-${search}`, value: `v-${search}` }],
totalCount: 1,
};
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('alpha');
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-alpha');
});
await type('beta');
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-beta');
});
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument();
});
test('refetches a dropped search response when the same search is repeated', async () => {
type OptionRow = { label: string; value: string | number };
type PageResponse = { data: OptionRow[]; totalCount: number };
// Resolves the in-flight loadOptions promise of the calling test.
let resolveAlpha: ((value: PageResponse) => void) | null = null;
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
const loadOptions = jest.fn((search: string) => {
if (search === '') {
return Promise.resolve<PageResponse>({
data: page0Data,
totalCount: 100,
});
}
if (search === 'alpha') {
// First call: hold the promise so it resolves only after beta returns.
// Second call (after beta): resolve immediately so the cache MUST allow
// a refetch.
if (!resolveAlpha) {
return new Promise<PageResponse>(resolve => {
resolveAlpha = resolve;
});
}
return Promise.resolve<PageResponse>({ data: alphaData, totalCount: 1 });
}
return Promise.resolve<PageResponse>({ data: betaData, totalCount: 1 });
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
await type('alpha');
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10));
// alpha's promise is held; switch to beta which resolves first.
await type('beta');
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-beta');
});
// Release the stale alpha response. It must be dropped — its key must not
// be cached, or returning to "alpha" later would short-circuit the fetch.
resolveAlpha!({ data: alphaData, totalCount: 1 });
await waitFor(async () => {
// Beta is still showing because alpha's response was dropped.
const options = await findAllSelectOptions();
expect(options[0]).toHaveTextContent('Match-beta');
});
// Returning to "alpha" must re-trigger the fetch (cache wasn't poisoned).
const callsBeforeAlphaReturn = loadOptions.mock.calls.filter(
args => args[0] === 'alpha',
).length;
await type('alpha');
await waitFor(() => {
const callsAfter = loadOptions.mock.calls.filter(
args => args[0] === 'alpha',
).length;
expect(callsAfter).toBeGreaterThan(callsBeforeAlphaReturn);
});
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options[0]).toHaveTextContent('Match-alpha');
});
});
test('keeps loading indicator while a newer request is in flight after a stale response is dropped', async () => {
// Regression for the P2 race: the `.finally` block that clears isLoading
// must not fire when a stale (dropped) response resolves while a newer
// request is still in flight. Otherwise the spinner disappears mid-search
// and the undebounced scroll-pagination handler can fire against stale
// totalCount before page 0 of the active search lands.
type OptionRow = { label: string; value: string | number };
type PageResponse = { data: OptionRow[]; totalCount: number };
// Initialized to no-op so the finally block can always call them, even if
// an assertion in the try throws before the corresponding mock ran.
let resolveAlpha: (value: PageResponse) => void = () => {};
let resolveBeta: (value: PageResponse) => void = () => {};
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
const loadOptions = jest.fn((search: string) => {
if (search === '') {
return Promise.resolve<PageResponse>({
data: page0Data,
totalCount: 100,
});
}
if (search === 'alpha') {
return new Promise<PageResponse>(resolve => {
resolveAlpha = resolve;
});
}
return new Promise<PageResponse>(resolve => {
resolveBeta = resolve;
});
});
const isSpinnerVisible = (): boolean =>
Boolean(document.querySelector('.ant-select-arrow .ant-spin'));
try {
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
// Type 'alpha' — alpha fetch is held, loading should be true.
await type('alpha');
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10),
);
await waitFor(() => expect(isSpinnerVisible()).toBe(true));
// Type 'beta' — beta fetch is also held; both are in flight.
await type('beta');
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('beta', 0, 10),
);
expect(isSpinnerVisible()).toBe(true);
// Release the stale alpha response. It is dropped at the early-return
// (search !== inputValueRef.current), but the in-flight counter is still
// non-zero because beta is pending — spinner must stay visible.
resolveAlpha({ data: alphaData, totalCount: 1 });
// Yield a microtask so alpha's .then/.finally runs, then re-assert.
await Promise.resolve();
expect(isSpinnerVisible()).toBe(true);
// Release beta. Now the in-flight counter drops to 0 and the spinner
// clears.
resolveBeta({ data: betaData, totalCount: 1 });
await waitFor(() => expect(isSpinnerVisible()).toBe(false));
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-beta');
} finally {
// Defensive: never leave a held promise that could hang a parallel worker
// if an assertion above threw. Promise resolve is idempotent.
resolveAlpha({ data: alphaData, totalCount: 1 });
resolveBeta({ data: betaData, totalCount: 1 });
}
});
test('re-shows search results when the same search term is repeated after a clear', async () => {
// Regression: a prior fix cached search responses' totalCount in
// fetchedQueries. After restore-on-clear had replaced selectOptions with
// the base list, re-typing a previously-resolved term would hit the cache
// short-circuit and leave selectOptions stale (empty / base-only).
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const alphaData = [{ label: 'Match-alpha', value: 'va' }];
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
// totalCount > data.length so allValuesLoaded stays false and the
// search path is not bypassed by the "all loaded" short-circuit.
return { data: page0Data, totalCount: 100 };
}
return { data: alphaData, totalCount: 1 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
await type('alpha');
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-alpha');
});
await type('');
await waitFor(() =>
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument(),
);
const callsBefore = loadOptions.mock.calls.filter(
args => args[0] === 'alpha',
).length;
await type('alpha');
await waitFor(() => {
const callsAfter = loadOptions.mock.calls.filter(
args => args[0] === 'alpha',
).length;
expect(callsAfter).toBeGreaterThan(callsBefore);
});
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Match-alpha');
});
});
test('appends page>1 results during an active search and discards them when search changes', async () => {
// Covers the production branch `else { mergeData(data) }` in fetchPage that
// fires when search is non-empty AND page > 0 — i.e. user scrolled within
// a multi-page search result. Switching to a new search must replace, not
// retain, the prior search's accumulated pages.
type OptionRow = { label: string; value: string | number };
const pageSize = 5;
const aliceData: OptionRow[] = Array.from({ length: 5 }, (_, i) => ({
label: `Alice-${i}`,
value: `a${i}`,
}));
const alicePage1: OptionRow[] = Array.from({ length: 3 }, (_, i) => ({
label: `Alice-${i + 5}`,
value: `a${i + 5}`,
}));
const bobData: OptionRow[] = [{ label: 'Bob-0', value: 'b0' }];
const loadOptions = jest.fn(
async (
search: string,
page: number,
): Promise<{
data: OptionRow[];
totalCount: number;
}> => {
if (search === '') {
return { data: [], totalCount: 100 };
}
if (search === 'alice') {
if (page === 0) return { data: aliceData, totalCount: 8 };
return { data: alicePage1, totalCount: 8 };
}
return { data: bobData, totalCount: 1 };
},
);
render(
<AsyncSelect {...defaultProps} pageSize={pageSize} options={loadOptions} />,
);
await open();
await type('alice');
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('alice', 0, pageSize),
);
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(5);
});
// Wait for loading to finish so handlePagination's `!isLoading` gate is
// open before we fire scroll.
await waitFor(() =>
expect(document.querySelector('.ant-select-arrow .ant-spin')).toBeNull(),
);
// Trigger pagination by dispatching a scroll event on the virtual-list
// scroll container. jsdom returns 0 for layout properties by default, so
// override the relevant ones before firing scroll. rc-virtual-list reads
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
// then forwards to onPopupScroll (handlePagination here).
const holder = document.querySelector(
'.rc-virtual-list-holder',
) as HTMLElement | null;
if (!holder) throw new Error('virtual-list holder not rendered');
Object.defineProperty(holder, 'scrollHeight', {
configurable: true,
get: () => 1000,
});
Object.defineProperty(holder, 'offsetHeight', {
configurable: true,
get: () => 200,
});
Object.defineProperty(holder, 'clientHeight', {
configurable: true,
get: () => 200,
});
Object.defineProperty(holder, 'scrollTop', {
configurable: true,
get: () => 900,
set: () => {},
});
fireEvent.scroll(holder);
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('alice', 1, pageSize),
);
await waitFor(async () => {
const options = await findAllSelectOptions();
// Page 0 (5) + page 1 (3) merged
expect(options).toHaveLength(8);
});
// Switching to a new search must replace the accumulated pages, not retain
// them.
await type('bob');
await waitFor(() =>
expect(loadOptions).toHaveBeenCalledWith('bob', 0, pageSize),
);
await waitFor(async () => {
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Bob-0');
});
expect(screen.queryByText('Alice-0')).not.toBeInTheDocument();
expect(screen.queryByText('Alice-7')).not.toBeInTheDocument();
});
test('does not duplicate options when using numeric values', async () => {
render(
<AsyncSelect
@@ -160,6 +160,13 @@ const AsyncSelect = forwardRef(
const [allValuesLoaded, setAllValuesLoaded] = useState(false);
const selectValueRef = useRef(selectValue);
const fetchedQueries = useRef(new Map<string, number>());
const initialOptionsRef = useRef<SelectOptionsType>(EMPTY_OPTIONS);
const inputValueRef = useRef('');
// Counts fetches whose `.finally` has not yet run. Loading is cleared only
// when this drops to 0, so a stale response (which returns early without
// updating selectOptions) cannot flip the spinner off while a newer
// request is still pending.
const inFlightFetchesRef = useRef(0);
const mappedMode = isSingleMode ? undefined : 'multiple';
const allowFetch = !fetchOnlyOnSearch || inputValue;
const [maxTagCount, setMaxTagCount] = useState(
@@ -183,6 +190,10 @@ const AsyncSelect = forwardRef(
selectValueRef.current = selectValue;
}, [selectValue]);
useEffect(() => {
inputValueRef.current = inputValue;
}, [inputValue]);
const sortSelectedFirst = useCallback(
(a: AntdLabeledValue, b: AntdLabeledValue) =>
sortSelectedFirstHelper(a, b, selectValueRef.current),
@@ -333,22 +344,78 @@ const AsyncSelect = forwardRef(
setIsLoading(true);
const fetchOptions = options as SelectOptionsPagePromise;
inFlightFetchesRef.current += 1;
fetchOptions(search, page, pageSize)
.then(({ data, totalCount }: SelectOptionsTypePage) => {
const mergedData = mergeData(data);
fetchedQueries.current.set(key, totalCount);
setTotalCount(totalCount);
if (
!fetchOnlyOnSearch &&
search === '' &&
mergedData.length >= totalCount
) {
setAllValuesLoaded(true);
// Drop responses whose search arg no longer matches the user's
// current input — otherwise a slow base fetch can land after a
// search fetch (or a stale debounced search after a clear) and
// re-pollute the dropdown via mergeData / search-replace. Search
// responses are never cached in fetchedQueries: the cache stores
// only totalCount, so a cache hit would short-circuit the fetch
// and leave selectOptions stale (e.g. after restore-on-clear).
// Re-issuing the search is cheap and correct.
const matchesCurrentSearch = inputValueRef.current === search;
if (search && !matchesCurrentSearch) {
return;
}
if (!search) {
// Accumulate base pages in a ref independent of selectOptions
// (during an active search, selectOptions holds search results
// and is not a safe accumulator). The accumulator is kept up
// to date even when this response landed during a search, so
// restore-on-clear has a complete snapshot. We don't sort here
// — restore-on-clear sorts a copy at consumption time, and the
// live selectOptions path below goes through mergeData which
// sorts there. Sorting here too would double the per-page sort
// cost on large cached option sets.
const dataValues = new Set(data.map(opt => opt.value));
const accumulated = initialOptionsRef.current
.filter(opt => !dataValues.has(opt.value))
.concat(data);
initialOptionsRef.current = accumulated;
if (!fetchOnlyOnSearch && accumulated.length >= totalCount) {
setAllValuesLoaded(true);
}
fetchedQueries.current.set(key, totalCount);
if (matchesCurrentSearch) {
// No active search — push to live selectOptions and update
// totalCount. When matchesCurrentSearch is false, the user
// is mid-search; leave the search's totalCount in place so
// pagination math stays correct.
mergeData(data);
setTotalCount(totalCount);
}
} else if (page === 0) {
// Replace cached options with server results; preserve
// optimistic isNewOption entries inserted by handleOnSearch
// so allowNewOptions users can still click the value they
// typed when the server returns no match.
setSelectOptions(prevOptions => {
const dataValues = new Set(data.map(opt => opt.value));
const preservedNew = prevOptions.filter(
opt => opt.isNewOption && !dataValues.has(opt.value),
);
return preservedNew
.concat(data)
.sort(sortComparatorForNoSearch);
});
setTotalCount(totalCount);
} else {
// page > 0 during an active search — append normally.
mergeData(data);
setTotalCount(totalCount);
}
})
.catch(internalOnError)
.finally(() => {
setIsLoading(false);
inFlightFetchesRef.current = Math.max(
0,
inFlightFetchesRef.current - 1,
);
if (inFlightFetchesRef.current === 0) {
setIsLoading(false);
}
});
},
[
@@ -358,6 +425,7 @@ const AsyncSelect = forwardRef(
internalOnError,
options,
pageSize,
sortComparatorForNoSearch,
],
);
@@ -500,6 +568,7 @@ const AsyncSelect = forwardRef(
fetchedQueries.current.clear();
setAllValuesLoaded(false);
setSelectOptions(EMPTY_OPTIONS);
initialOptionsRef.current = EMPTY_OPTIONS;
}, [options]);
useEffect(() => {
@@ -514,16 +583,36 @@ const AsyncSelect = forwardRef(
[debouncedFetchPage],
);
const previousInputValue = usePrevious(inputValue, '');
useEffect(() => {
if (loadingEnabled && allowFetch) {
// trigger fetch every time inputValue changes
if (inputValue) {
debouncedFetchPage(inputValue, 0);
} else {
// Cancel any pending debounced search fetch so it can't fire after
// we've already restored the base list.
debouncedFetchPage.cancel();
// On returning to empty input after a search, restore the cached
// base options so the dropdown shows the original page-0 list
// instead of the stale search results.
if (previousInputValue && initialOptionsRef.current.length > 0) {
setSelectOptions(
[...initialOptionsRef.current].sort(sortComparatorForNoSearch),
);
}
fetchPage('', 0);
}
}
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
}, [
loadingEnabled,
fetchPage,
allowFetch,
inputValue,
previousInputValue,
debouncedFetchPage,
sortComparatorForNoSearch,
]);
useEffect(() => {
if (loading !== undefined && loading !== isLoading) {
@@ -531,7 +620,11 @@ const AsyncSelect = forwardRef(
}
}, [isLoading, loading]);
const clearCache = () => fetchedQueries.current.clear();
const clearCache = () => {
fetchedQueries.current.clear();
initialOptionsRef.current = EMPTY_OPTIONS;
setAllValuesLoaded(false);
};
useImperativeHandle(
ref,
@@ -211,6 +211,10 @@ export const handleFilterOptionHelper = (
return filterOption(search, option);
}
if (filterOption === false) {
return true;
}
if (filterOption) {
const searchValue = search.trim().toLowerCase();
if (optionFilterProps?.length) {
@@ -371,3 +371,37 @@ test('should handle large datasets with pagination', () => {
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
});
test('should reset to first page when data reduces below current page', async () => {
// Start with 30 items, 10 per page = 3 pages
const initialData = Array.from({ length: 30 }, (_, i) => ({
id: i,
age: 20 + i,
name: `Person ${i}`,
}));
const props = {
...mockedProps,
data: initialData,
pageSize: 10,
};
const { rerender } = render(<TableView {...props} />);
// Navigate to page 3 (last page)
const page3 = screen.getByRole('listitem', { name: '3' });
await userEvent.click(page3);
await waitFor(() => {
expect(screen.getByText('21-30 of 30')).toBeInTheDocument();
});
// Reduce data to only 5 items (fewer than current page would show)
const reducedData = initialData.slice(0, 5);
rerender(<TableView {...props} data={reducedData} />);
// Should reset to page 1 since page 3 no longer exists
await waitFor(() => {
expect(screen.getByText('1-5 of 5')).toBeInTheDocument();
});
});
@@ -246,6 +246,21 @@ const RawTableView = ({
}
}, [initialSortBy, onServerPagination, serverPagination, sortBy]);
// Reset to first page when current page exceeds available pages
// (e.g., when filtering reduces the data below the current page)
const pageCount = Math.ceil(data.length / effectivePageSize);
useEffect(() => {
if (
withPagination &&
!serverPagination &&
!loading &&
pageIndex > pageCount - 1 &&
pageCount > 0
) {
setPageIndex(0);
}
}, [withPagination, serverPagination, loading, pageIndex, pageCount]);
return (
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
@@ -77,6 +77,7 @@ export interface ChartDataResponseResult {
// TODO(hainenber): define proper type for below attributes
rejected_filters?: any[];
applied_filters?: any[];
warning?: string | null;
/**
* Detected ISO 4217 currency code when AUTO mode is used.
* Returns the currency code if all filtered data contains a single currency,
@@ -162,7 +162,7 @@ export function generateMultiLineTooltipContent(d, xFormatter, yFormatters) {
tooltip += '</tbody></table>';
return tooltip;
return dompurify.sanitize(tooltip);
}
export function generateTimePivotTooltip(d, xFormatter, yFormatter) {
@@ -223,7 +223,7 @@ export function generateBubbleTooltipContent({
s += createHTMLRow(getLabel(sizeField), sizeFormatter(point.size));
s += '</table>';
return s;
return dompurify.sanitize(s);
}
// shouldRemove indicates whether the nvtooltips should be removed from the DOM
@@ -287,9 +287,11 @@ export function tipFactory(layer) {
? layer.descriptionColumns.map(c => d[c])
: Object.values(d);
return `<div><strong>${title}</strong></div><br/><div>${body.join(
', ',
)}</div>`;
return dompurify.sanitize(
`<div><strong>${title}</strong></div><br/><div>${body.join(
', ',
)}</div>`,
);
});
}
@@ -26,6 +26,9 @@ import {
computeYDomain,
getTimeOrNumberFormatter,
formatLabel,
generateBubbleTooltipContent,
generateMultiLineTooltipContent,
tipFactory,
} from '../src/utils';
const DATA = [
@@ -181,4 +184,61 @@ describe('nvd3/utils', () => {
]);
});
});
describe('tooltip HTML sanitization', () => {
const identity = (v: unknown) => v;
test('generateBubbleTooltipContent strips scripts from entity/group', () => {
const html = generateBubbleTooltipContent({
point: {
name: '<img src=x onerror="alert(1)">',
group: '<script>alert(2)</script>',
color: 'red',
x: 1,
y: 2,
size: 3,
},
entity: 'name',
xField: 'x',
yField: 'y',
sizeField: 'size',
xFormatter: identity,
yFormatter: identity,
sizeFormatter: identity,
});
expect(html).not.toContain('onerror');
expect(html).not.toContain('<script>');
});
test('generateMultiLineTooltipContent strips scripts from series keys', () => {
const html = generateMultiLineTooltipContent(
{
value: 'x',
series: [
{ key: '<img src=x onerror="alert(1)">', color: 'red', value: 1 },
],
},
identity,
[identity],
);
expect(html).not.toContain('onerror');
});
test('tipFactory strips scripts from annotation data values', () => {
const tip = tipFactory({
titleColumn: 'title',
name: 'layer',
descriptionColumns: ['desc'],
});
const html = tip.html()({
title: '<img src=x onerror="alert(1)">',
desc: '<script>alert(2)</script>',
});
expect(html).not.toContain('onerror');
expect(html).not.toContain('<script>');
});
});
});
@@ -29,7 +29,7 @@
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"zod": "^4.4.1"
"zod": "^4.4.3"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -239,8 +239,6 @@ export default function transformProps(
formatter,
show: showLabels,
color: theme.colorText,
textBorderColor: theme.colorBgBase,
textBorderWidth: 1,
};
const legendData = keys.sort((a: string, b: string) => {
if (!legendSort) return 0;
@@ -21,8 +21,8 @@ import { TreePathInfo } from '../types';
export const COLOR_SATURATION = [0.7, 0.4];
export const LABEL_FONTSIZE = 11;
export const BORDER_WIDTH = 2;
export const GAP_WIDTH = 2;
export const BORDER_WIDTH = 0;
export const GAP_WIDTH = 0;
export const extractTreePathInfo = (
treePathInfo: TreePathInfo[] | undefined,
@@ -214,7 +214,8 @@ export default function transformProps(
colorAlpha: OpacityEnum.SemiTransparent,
color: theme.colorText,
borderColor: theme.colorBgBase,
borderWidth: 2,
borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH,
},
label: {
...labelProps,
@@ -72,6 +72,15 @@ describe('Funnel transformProps', () => {
}),
);
});
test('does not apply a text border to segment labels', () => {
// A white textBorder washes out the dark text on light-colored segments.
const result = transformProps(chartProps as EchartsFunnelChartProps);
const { label } = (result.echartOptions.series as any)[0];
expect(label.color).toBe(supersetTheme.colorText);
expect(label.textBorderColor).toBeUndefined();
expect(label.textBorderWidth).toBeUndefined();
});
});
describe('formatFunnelLabel', () => {

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