Compare commits

...
Author SHA1 Message Date
Ville Brofeldt 423e5280d0 feat(extensions): autodetect contributions 2026-02-25 09:53:58 -08:00
Ville Brofeldt c5dce675a0 feat(extensions): autogenerate fe and be contributions 2026-02-25 09:22:49 -08:00
Amin GhadersohiandClaude Opus 4.6 c54b21ef98 fix(mcp): add eager loading to get_info tools to prevent N+1 query timeouts (#38129)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:28:58 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 438a927420 chore(deps-dev): bump oxlint from 1.49.0 to 1.50.0 in /superset-frontend (#38240)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 23:10:37 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 37a4637018 chore(deps-dev): bump typescript-eslint from 8.56.0 to 8.56.1 in /superset-websocket (#38203)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 23:09:41 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 79b2647481 chore(deps): bump @swc/core from 1.15.11 to 1.15.13 in /docs (#38207)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 23:00:21 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1b605c4dda chore(deps): bump fs-extra from 11.3.2 to 11.3.3 in /superset-frontend (#38234)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 22:59:44 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b543358d2f chore(deps-dev): bump @swc/core from 1.15.11 to 1.15.13 in /superset-frontend (#38237)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 22:59:14 +07:00
Amin GhadersohiandClaude Opus 4.5 a1312a86e8 fix(mcp): normalize column names to fix time series filter prompt issue (#37187)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:27:53 +01:00
Amin GhadersohiandClaude Opus 4.5 3084907931 feat(mcp): support unsaved state in Explore and Dashboard tools (#37183)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:25:23 +01:00
Amin Ghadersohi 1cd35bb102 feat(mcp): dynamic feature availability via menus and feature flags (#37964) 2026-02-25 12:01:44 +01:00
Joe LiandClaude Opus 4.6 5eb35a4795 fix(reports): validate database field on PUT report schedule (#38084)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:58:19 -08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 01c1b2eb8f chore(deps-dev): bump @types/lodash from 4.17.23 to 4.17.24 in /superset-frontend (#38224)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 07:52:22 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9e4a88dfa2 chore(deps): bump antd from 6.3.0 to 6.3.1 in /docs (#38221)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 07:51:39 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4809903bb8 chore(deps): bump markdown-to-jsx from 9.7.4 to 9.7.6 in /superset-frontend (#38225)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 07:51:11 +07:00
Đỗ Trọng Hải 76a2559b2b fix(ci): revert "chore(deps): bump JustinBeckwith/linkinator-action from 2.3 to 2.4" (#38164) 2026-02-24 13:22:29 -08:00
Mehmet Salih Yavuz e4a7cd30c3 fix(GAQ): don't use async queries when cache timeout is -1 (#38089) 2026-02-24 23:21:37 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> aa475734ef chore(deps-dev): bump eslint from 9.39.2 to 10.0.2 in /superset-websocket (#38204)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 10:30:17 -08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 97b8585fe5 chore(deps-dev): bump typescript-eslint from 8.56.0 to 8.56.1 in /docs (#38209)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 10:30:05 -08:00
Đỗ Trọng Hải 0d66accc37 chore(build): prevent opening Dependabot PRs for @rjsf/* deps due to React 18 constraint (#37976)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-24 10:28:05 -08:00
Ville Brofeldt 35c135852e feat(extensions): add mandatory publisher field to extension metadata (#38200) 2026-02-24 09:42:17 -08:00
Evan RusackasandClaude Opus 4.6 7b04d251d6 fix(build): restore automatic .d.ts generation in dev mode (#38202)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:27:25 -08:00
Michael S. Molina 974bee14c3 fix(extensions): make LOCAL_EXTENSIONS loading resilient to individual failures (#38217) 2026-02-24 13:17:27 -03:00
fca8a49561 feat: auto refresh dashboard (#37459)
Co-authored-by: Richard <richard@ip-192-168-1-32.sa-east-1.compute.internal>
Co-authored-by: richard <richard@richards-MacBook-Pro-2.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-02-24 11:37:28 -03:00
Vitor Avila f60432e34c fix: Allow non-owners to fave/unfave charts (#38095) 2026-02-24 11:28:32 -03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b8459c15b8 chore(deps-dev): bump @typescript-eslint/parser from 8.56.0 to 8.56.1 in /docs (#38211)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 20:06:14 +07:00
Evan Rusackas 8eb3046888 fix(docs): guard window reference in logging.ts for SSR compatibility (#38201) 2026-02-23 18:41:49 -08:00
Evan RusackasandClaude Opus 4.5 615f13419c fix(jest): ignore storybook-static and package __mocks__ directories (#37946)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 16:18:14 -08:00
Evan Rusackas 8a74424545 fix(types): add explicit types for extendedDayjs plugin methods (#37923) 2026-02-24 06:58:46 +07:00
madhushreeagandmadhushree agarwal 8f070169a5 perf(datasource): add pagination to datasource editor tables to prevent browser freeze (#37555)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-02-23 15:19:33 -08:00
Richard Fogaca Nienkotterandrichard e06427d1ef feat(embedded): add feature flag to disable logout button in embedded contexts (#37537)
Co-authored-by: richard <richard@richards-MacBook-Pro-2.local>
2026-02-23 17:56:02 -03:00
Evan RusackasandClaude Opus 4.5 c4eb7de6de fix(excel): remove unwanted index column from Excel exports (#38176)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 08:28:40 -08:00
Vitor Avila 228b598409 feat: Labels for encrypted fields (#38075) 2026-02-23 13:23:33 -03:00
Ville Brofeldt 40f609fdce fix(extensions): enforce correct naming conventions (#38167) 2026-02-23 08:21:35 -08:00
Amin GhadersohiandClaude Opus 4.6 6e94a6c21a fix(mcp): fix dashboard chart placement with proper COLUMN layout and tab support (#37970)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:41:10 +01:00
50cc1b93d2 fix(security): fix Guest Token API 422 error by disabling JWT sub claim verification (#38177)
Co-authored-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 21:58:01 +07:00
Evan RusackasandClaude Opus 4.5 131a97b657 fix(handlebars): add missing currencyformatter.js dependency (#38173)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 21:56:50 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6f3a200c19 chore(deps-dev): bump @types/lodash from 4.17.23 to 4.17.24 in /superset-websocket (#38179)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 21:54:18 +07:00
Michael S. Molina cbb80f0462 refactor(extensions): simplify registerEditorProvider API (#38127) 2026-02-23 09:04:31 -03:00
Amin Ghadersohi 2a3567d2f1 fix(mcp): Remove unsupported thumbnail/preview URLs and internal fields from MCP schemas (#38109) 2026-02-23 12:44:12 +01:00
Evan RusackasandClaude Opus 4.5 3f64ad3da5 fix(i18n): wrap untranslated frontend strings and add i18n lint rule (#37776)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-22 21:27:37 -08:00
Evan Rusackas 672a380587 chore(frontend): enable additional oxlint rules for better code hygiene (#38145) 2026-02-23 10:36:24 +07:00
Rohan Santhoshandrohan436 a87a006aae ci: declare explicit permissions in maintenance workflows (#38159)
Co-authored-by: rohan436 <rohan.santhoshkumar@googlemail.com>
2026-02-22 12:05:58 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 159fb5d6f4 chore(deps-dev): bump ajv from 6.12.6 to 6.14.0 in /superset-frontend/cypress-base (#38131)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 17:34:27 -08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6424194c87 chore(deps): bump underscore from 1.13.7 to 1.13.8 in /superset-frontend (#38142)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 13:25:26 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5bee32ea93 chore(deps): bump aquasecurity/trivy-action from 0.34.0 to 0.34.1 (#38138)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 11:45:40 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 82fce8d7de chore(deps-dev): bump @types/node from 25.2.3 to 25.3.0 in /superset-frontend (#38143)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 11:45:06 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
5e6524954c chore(deps): pin react-icons to 5.4.0 in /superset-frontend (#38144)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-21 11:44:46 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 987b6a6f04 chore(deps): bump swagger-ui-react from 5.31.1 to 5.31.2 in /docs (#38140)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 07:43:21 +07:00
Đỗ Trọng Hải 3d6644864d build(deps): migrate to lighter and modern react-icons (#38125)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-20 16:19:01 -08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 577b965a60 chore(deps-dev): bump ajv from 6.12.6 to 6.14.0 in /superset-frontend (#38132)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 06:51:28 +07:00
Enzo Martellucci b565128fe7 fix(charts): improve error display for failed charts in dashboards (#37939) 2026-02-20 15:14:48 -08:00
madhushreeagandmadhushree agarwal b290f71245 fix(explore): prevent theme object from being passed to ReactAce in TextAreaControl (#38117)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-02-20 14:16:07 -08:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
cff854b06e chore(deps-dev): bump oxlint from 1.48.0 to 1.49.0 in /superset-frontend (#38115)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-20 14:13:37 -08:00
Manoj S 44d6b6a513 fix(table): preserve line breaks in cell content modal (#37036) 2026-02-20 14:12:14 -08:00
Ujjwaljain16 2d44f52ad1 fix(encryption): resolve SECRET_KEY lazily to fix silent re-encrypt-secrets failures (#37982) 2026-02-20 14:10:09 -08:00
wuqicyber 6f34ba7d4a fix(table-chart): support orderby adhoc columns with server-side pagination (#37521) 2026-02-21 00:29:34 +03:00
Damian Pendrak 1a77e17179 fix(chart-customizations): support migration of dynamic group by (#37176) 2026-02-20 13:11:07 -08:00
Gabriel Torres Ruiz 6fdaa8e9b3 fix(crud): reorder table actions + improve react memoization + improve hooks (#37897) 2026-02-20 08:58:28 -08:00
Kamil GabryjelskiandClaude Opus 4.6 e30a9caba5 fix(dataset-modal): fix folders tab scrollbar by establishing proper flex chain (#38123)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:54:23 +01:00
Kamil GabryjelskiandClaude Opus 4.6 7937246575 fix(button): use colorLink token for link-style buttons (#38121)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:54:05 +01:00
Amin Ghadersohi 9f8b212ccc feat(mcp): add LIKE, ILIKE, IN, NOT IN filter operators to MCP chart tools (#38071) 2026-02-20 11:56:40 +01:00
Amin GhadersohiandClaude Opus 4.6 1ecff6fe5c fix(thumbnails): stabilize digest by sorting datasources and charts (#38079)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:51:35 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 69653dfd08 chore(deps-dev): bump baseline-browser-mapping from 2.9.19 to 2.10.0 in /superset-frontend (#38116)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 15:35:18 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 58d8aa01f8 chore(deps): bump react-intersection-observer from 10.0.2 to 10.0.3 in /superset-frontend (#38114)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 15:34:15 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 88f0e322e3 chore(deps): bump baseline-browser-mapping from 2.9.19 to 2.10.0 in /docs (#38113)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 15:33:56 +07:00
Vanessa Giannoni f4acce5727 fix(table): preserve time grain aggregation when temporal column casing changes (#37893) 2026-02-19 16:46:39 -08:00
Richard Fogaca Nienkotter 5278deaf63 fix(metrics): normalize legacy currency strings (#37455) 2026-02-19 21:25:44 -03:00
Mehmet Salih Yavuz 3868821dc8 fix(webpack): skip building service worker in dev (#38106) 2026-02-20 00:26:16 +03:00
Joe LiandClaude Opus 4.6 6a61baf5be fix(alerts): show friendly filter names in report edit modal (#38054)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 10:33:33 -08:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
5cc8ae5427 chore(deps): bump ol from 7.5.2 to 10.8.0 in /superset-frontend (#37961)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-19 10:32:32 -08:00
Michael S. Molina 1f76944c2b fix: Add editors to ContributionConfig and additional properties to EditorKeyword (#38098) 2026-02-19 15:00:21 -03:00
Kamil Gabryjelski f049d3e34a fix: Search in folders editor with verbose names (#38101) 2026-02-19 18:45:22 +01:00
Kamil Gabryjelski 86c8fa5cd7 fix: Badge count in folders editor (#38100) 2026-02-19 18:45:04 +01:00
Kamil Gabryjelski e12140beb6 fix: Warning toast copy in folders editor (#38099) 2026-02-19 18:22:22 +01:00
Kamil Gabryjelski b7a3224f04 feat: Larger folder drag area in folders editor (#38102) 2026-02-19 18:22:04 +01:00
Kamil GabryjelskiandClaude Opus 4.6 f5a5a804e2 perf(dashboard): skip thumbnail_url computing on single dashboard endpoint (#38015)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:15:20 +01:00
Đỗ Trọng Hải 0b77ace110 chore: fix lint issue with no-unsafe-optional-chaining rule (#38103)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-19 23:54:37 +07:00
Levis Mbote c175346808 fix(table-charts): Prevent time grain from altering Raw Records in Tables + Interactive Tables (#37561) 2026-02-19 10:24:09 +01:00
Evan RusackasandClaude Opus 4.5 6b80135aa2 chore(lint): enforce more strict eslint/oxlint rules (batch 2) (#37884)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 19:27:27 -08:00
RealGreenDragon de079a7b19 feat(deps)!: bump postgresql from 16 to 17 (#37782) 2026-02-18 17:12:48 -08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f54bbdc06b chore(deps): bump dawidd6/action-download-artifact from 14 to 15 (#38060)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 17:11:41 -08:00
SBIN2010 33441ccf3d feat: add formatting column and formatting object to conditional formating table (#35897) 2026-02-19 02:07:15 +03:00
Vitor Avila 9ec56f5f02 fix: Include app_root in next param (#37942) 2026-02-18 19:52:06 -03:00
dependabot[bot] 11a36ff488 chore(deps-dev): bump the storybook group across 1 directory with 11 updates (#38068) 2026-02-18 23:48:16 +07:00
Đỗ Trọng Hải af3e088233 build(deps): resolve GHSA-36jr-mh4h-2g58 by upgrading d3-color to 3.1.0 (#37981)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-18 21:12:39 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 29f499528f chore(deps-dev): bump eslint-plugin-testing-library from 7.15.4 to 7.16.0 in /superset-frontend (#38066)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 21:01:04 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 21481eef4f chore(deps): bump the storybook group in /docs with 9 updates (#38067)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 21:00:01 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
0d2c8fd373 chore(deps): bump @storybook/core from 8.6.15 to 8.6.16 in /docs (#38046)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-18 20:22:21 +07:00
Đỗ Trọng Hải 7b56fc1714 fix(docs): correct DB module filename for editing + update DB metadata file (#37990)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-18 20:08:50 +07:00
Đỗ Trọng Hải 9131739f98 fix(home): null check for possibly undefined filtered other table data due to insufficient permission (#37983) 2026-02-18 17:33:51 +07:00
Đỗ Trọng Hải a30492f55e fix(plugin/cal-heatmap): properly color tooltip's text for both dark/light theme (#38010) 2026-02-18 17:25:41 +07:00
dependabot[bot] 090eab099c chore(deps): bump storybook from 8.6.15 to 8.6.16 in /docs (#38043) 2026-02-18 16:23:26 +07:00
dependabot[bot] cd4cd53726 chore(deps-dev): bump css-loader from 7.1.3 to 7.1.4 in /superset-frontend (#38050) 2026-02-18 16:21:39 +07:00
dependabot[bot] 65c460c9d2 chore(deps-dev): bump @swc/plugin-emotion from 14.5.0 to 14.6.0 in /superset-frontend (#38053) 2026-02-18 16:20:49 +07:00
dependabot[bot] 868e719c60 chore(deps-dev): bump oxlint from 1.47.0 to 1.48.0 in /superset-frontend (#38055) 2026-02-18 16:20:16 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
5efc7ea5a5 chore(deps-dev): bump typescript-eslint from 8.55.0 to 8.56.0 in /docs (#38024)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-18 12:10:50 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b0f9a73f63 chore(deps-dev): bump typescript-eslint from 8.54.0 to 8.56.0 in /superset-websocket (#38020)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 11:49:11 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 746e266e90 chore(deps): bump swagger-ui-react from 5.31.0 to 5.31.1 in /docs (#38023)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 11:37:51 +07:00
Damian Pendrak 5a777c0f45 feat(matrixify): add single metric constraint (#37169) 2026-02-17 09:12:24 -08:00
Amin GhadersohiandClaude Opus 4.6 aec1f6edce fix(mcp): use last data-bearing statement in execute_sql response (#37968)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:13:55 +01:00
Amin Ghadersohi f7218e7a19 feat(mcp): expose current user identity in get_instance_info and add created_by_fk filter (#37967) 2026-02-17 13:11:34 +01:00
Amin GhadersohiandClaude Opus 4.6 5cd829f13c fix(mcp): handle more chart types in get_chart_data fallback query construction (#37969)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:02:42 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9566e8a9c6 chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 0.8.5 to 0.9.1 in /superset-frontend (#38000)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-17 12:03:13 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 604d49f557 chore(deps): bump datamaps from 0.5.9 to 0.5.10 in /superset-frontend (#37913)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 14:51:03 -08:00
SBIN2010 84f1ee4409 feat: added conditional formatting enhancements string to pivot table (#35863) 2026-02-17 01:08:41 +03:00
Kamil Gabryjelski 3e3c9686de perf(dashboard): Batch RLS filter lookups for dashboard digest computation (#37941) 2026-02-16 21:35:55 +01:00
Mehmet Salih Yavuz 7b21979fa3 fix(charts): Force refresh uses async mode when GAQ is enabled (#37845) 2026-02-16 21:45:10 +03:00
Đỗ Trọng Hải 8853ff19d4 chore(websocket): migrate external uuid usage with Node's native UUID generator (#37101)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-16 18:05:10 +07:00
Damian Pendrak 22ac5e02b6 fix(deckgl): remove dataset field from Deck.gl Layer Visibility Display controls (#37611) 2026-02-16 11:58:23 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2c9f0c1c2a chore(deps-dev): bump wait-on from 9.0.3 to 9.0.4 in /superset-frontend (#37999)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 17:18:23 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d47a7105df chore(deps): bump caniuse-lite from 1.0.30001769 to 1.0.30001770 in /docs (#37994)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:42:53 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c873225308 chore(deps-dev): bump jsdom from 28.0.0 to 28.1.0 in /superset-frontend (#37997)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:42:28 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 982e2c1ef7 chore(deps-dev): bump webpack from 5.105.0 to 5.105.2 in /superset-frontend (#38003)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:36:17 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> eee3af5775 chore(deps-dev): bump oxlint from 1.46.0 to 1.47.0 in /superset-frontend (#38005)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:35:29 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 232b34d944 chore(deps-dev): bump webpack-sources from 3.3.3 to 3.3.4 in /superset-frontend (#38004)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:35:03 +07:00
dependabot[bot] d748ed19ce chore(deps): bump hot-shots from 13.2.0 to 14.0.0 in /superset-websocket (#37993) 2026-02-16 15:16:31 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5300f65a74 chore(deps): bump qs from 6.14.1 to 6.14.2 in /superset-frontend (#37936)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 13:39:06 +07:00
Türker Ziya Ercin 440602ef34 fix(utils): datetime_to_epoch function is fixed to timezone aware epoch (#37979) 2026-02-15 22:36:18 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cbf153845e chore(deps): bump qs from 6.14.1 to 6.14.2 in /superset-websocket/utils/client-ws-app (#37933)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 22:18:14 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 097f474f24 chore(deps): bump pillow from 11.3.0 to 12.1.1 (#37935)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 16:00:47 -08:00
Joe Li 73adff55ee chore(deps): Relax sqlalchemy-utils lower bound for pydoris compatibility (#37949) 2026-02-13 14:55:54 -08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a65f73a532 chore(deps): bump qs from 6.14.1 to 6.14.2 in /docs (#37937)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 01:01:42 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 475615e118 chore(deps): bump ioredis from 5.9.2 to 5.9.3 in /superset-websocket (#37951)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 23:40:50 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 79f51e2ae7 chore(deps-dev): bump webpack from 5.105.1 to 5.105.2 in /docs (#37953)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 23:39:56 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 75d6a95ac3 chore(deps): bump aquasecurity/trivy-action from 0.33.1 to 0.34.0 (#37958)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 23:39:30 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ffd7f10320 chore(deps): bump markdown-to-jsx from 9.7.3 to 9.7.4 in /superset-frontend (#37959)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 23:09:31 +07:00
Michael S. Molina e3e2bece6b feat(owners): display email in owner selectors (#37906) 2026-02-13 09:01:05 -03:00
Jean Massucatto 0c0d915391 fix(echarts-timeseries-combined-labels): combine annotation labels for events at same timestamp (#37164) 2026-02-13 12:39:28 +03:00
Jamile Celento 080f629ea2 fix(echarts): formula annotations not rendering with dataset-level columns label (#37522) 2026-02-13 12:37:19 +03:00
Joe LiandClaude Opus 4.6 142b2cc425 test(e2e): add Playwright E2E tests for Chart List page (#37866)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:16:11 -08:00
Joe LiandClaude Opus 4.5 6328e51620 test(examples): add tests for UUID threading and security bypass (#37557)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 14:12:12 -08:00
0d5ddb3674 feat(themes): add enhanced validation and error handling with fallback mechanisms (#37378)
Co-authored-by: Rafael Benitez <rebenitez1802@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-12 14:06:58 -08:00
Pat Buxton 58d245c6b0 chore(deps): Update sqlachemy-utils to 0.42.0 (#36240) 2026-02-12 12:39:06 -08:00
dbf5e1f131 feat(theme): use IBM Plex Mono for code and numerical displays (#37366)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:32:41 -08:00
Jonathan Alberth Quispe Fuentes 88ce1425e2 fix(roles): optimize user fetching and resolve N+1 query issue (#37235) 2026-02-12 09:32:19 -08:00
Amin Ghadersohi 4dfece9ee5 feat(mcp): add event_logger instrumentation to MCP tools (#37859) 2026-02-12 16:50:20 +01:00
Amin GhadersohiandClaude Opus 4.6 3f64c25712 fix(mcp): Add database_name as valid filter column for list_datasets (#37865)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:47:46 +01:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
afacca350f chore(deps-dev): bump oxlint from 1.42.0 to 1.46.0 in /superset-frontend (#37917)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-12 21:45:26 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 30ccbb2e05 chore(deps): update @types/geojson requirement from ^7946.0.10 to ^7946.0.16 in /superset-frontend/plugins/plugin-chart-cartodiagram (#37908)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-12 20:59:28 +07:00
Michael S. Molina 19ec7b48a0 fix: Conditional formatting painting empty cells (#37894) 2026-02-12 10:22:00 -03:00
524 changed files with 96287 additions and 24258 deletions
+25
View File
@@ -16,9 +16,12 @@ updates:
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eslint-plugin-storybook"
- dependency-name: "react-error-boundary"
- dependency-name: "@rjsf/*"
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
- dependency-name: "remark-gfm"
- dependency-name: "react-markdown"
# TODO: remove below entries until React >= 19.0.0
- dependency-name: "react-icons"
# JSDOM v30 doesn't play well with Jest v30
# Source: https://jestjs.io/blog#known-issues
# GH thread: https://github.com/jsdom/jsdom/issues/3492
@@ -65,6 +68,22 @@ updates:
- package-ecosystem: "npm"
directory: "/docs/"
ignore:
# TODO: remove below entries until React >= 18.0.0 in superset-frontend
- dependency-name: "storybook"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "@storybook*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eslint-plugin-storybook"
- dependency-name: "react-error-boundary"
groups:
storybook:
applies-to: version-updates
patterns:
- "@storybook*"
- "storybook"
update-types:
- "patch"
schedule:
interval: "daily"
open-pull-requests-limit: 10
@@ -123,6 +142,9 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
ignore:
# TODO: remove below entries until React >= 19.0.0
- dependency-name: "react-icons"
schedule:
interval: "daily"
labels:
@@ -173,6 +195,9 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-table/"
ignore:
# TODO: remove below entries until React >= 19.0.0
- dependency-name: "react-icons"
schedule:
interval: "daily"
labels:
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
# Scan for vulnerabilities in built container image after pushes to mainline branch.
- name: Run Trivy container image vulnerabity scan
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'lean'
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # v0.34.1
with:
image-ref: ${{ env.IMAGE_TAG }}
format: 'sarif'
+3
View File
@@ -4,6 +4,9 @@ on:
pull_request:
types: [labeled, unlabeled, opened, reopened, synchronize]
permissions:
pull-requests: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+3
View File
@@ -8,6 +8,9 @@ on:
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
services:
postgres:
image: postgres:16-alpine
image: postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
+2 -2
View File
@@ -68,7 +68,7 @@ jobs:
yarn install --check-cache
- name: Download database diagnostics (if triggered by integration tests)
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
uses: dawidd6/action-download-artifact@v14
uses: dawidd6/action-download-artifact@v15
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
@@ -77,7 +77,7 @@ jobs:
path: docs/src/data/
- name: Try to download latest diagnostics (for push/dispatch triggers)
if: github.event_name != 'workflow_run'
uses: dawidd6/action-download-artifact@v14
uses: dawidd6/action-download-artifact@v15
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v6
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@f62ba0c110a76effb2ee6022cc6ce4ab161085e3 # v2.4
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
with:
paths: "**/*.md, **/*.mdx"
@@ -111,7 +111,7 @@ jobs:
run: |
yarn install --check-cache
- name: Download database diagnostics from integration tests
uses: dawidd6/action-download-artifact@v14
uses: dawidd6/action-download-artifact@v15
with:
workflow: superset-python-integrationtest.yml
run_id: ${{ github.event.workflow_run.id }}
+2 -2
View File
@@ -54,7 +54,7 @@ jobs:
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true' || 'false' }}
services:
postgres:
image: postgres:16-alpine
image: postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -171,7 +171,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: postgres:16-alpine
image: postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: postgres:16-alpine
image: postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -115,7 +115,7 @@ jobs:
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
services:
postgres:
image: postgres:16-alpine
image: postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -25,7 +25,7 @@ jobs:
SUPERSET__SQLALCHEMY_EXAMPLES_URI: presto://localhost:15433/memory/default
services:
postgres:
image: postgres:16-alpine
image: postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -94,7 +94,7 @@ jobs:
UPLOAD_FOLDER: /tmp/.superset/uploads/
services:
postgres:
image: postgres:16-alpine
image: postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
+3
View File
@@ -6,6 +6,9 @@ on:
- master
- "[0-9].[0-9]*"
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
+36
View File
@@ -24,6 +24,42 @@ assists people when migrating to a new version.
## Next
### MCP Tool Observability
MCP (Model Context Protocol) tools now include enhanced observability instrumentation for monitoring and debugging:
**Two-layer instrumentation:**
1. **Middleware layer** (`LoggingMiddleware`): Automatically logs all MCP tool calls with `duration_ms` and `success` status in the audit log (Action Log UI, logs table)
2. **Sub-operation tracking**: All 19 MCP tools include granular `event_logger.log_context()` blocks for tracking individual operations like validation, database writes, and query execution
**Action naming convention:**
- Tool-level logs: `mcp_tool_call` (via middleware)
- Sub-operation logs: `mcp.{tool_name}.{operation}` (e.g., `mcp.generate_chart.validation`, `mcp.execute_sql.query_execution`)
**Querying MCP logs:**
```sql
-- Top slowest MCP operations
SELECT action, COUNT(*) as calls, AVG(duration_ms) as avg_ms
FROM logs
WHERE action LIKE 'mcp.%'
GROUP BY action
ORDER BY avg_ms DESC
LIMIT 20;
-- MCP tool success rate
SELECT
json_extract(curated_payload, '$.tool') as tool,
COUNT(*) as total_calls,
SUM(CASE WHEN json_extract(curated_payload, '$.success') = 'true' THEN 1 ELSE 0 END) as successful,
ROUND(100.0 * SUM(CASE WHEN json_extract(curated_payload, '$.success') = 'true' THEN 1 ELSE 0 END) / COUNT(*), 2) as success_rate
FROM logs
WHERE action = 'mcp_tool_call'
GROUP BY tool
ORDER BY total_calls DESC;
```
**Security note:** Sensitive parameters (passwords, API keys, tokens) are automatically redacted in logs as `[REDACTED]`.
### Signal Cache Backend
A new `SIGNAL_CACHE_CONFIG` configuration provides a unified Redis-based backend for real-time coordination features in Superset. This backend enables:
+1 -1
View File
@@ -45,7 +45,7 @@ services:
required: true
- path: docker/.env-local # optional override
required: false
image: postgres:16
image: postgres:17
container_name: superset_db
restart: unless-stopped
volumes:
+1 -1
View File
@@ -85,7 +85,7 @@ services:
required: true
- path: docker/.env-local # optional override
required: false
image: postgres:16
image: postgres:17
restart: unless-stopped
volumes:
- db_home_light:/var/lib/postgresql/data
+1 -1
View File
@@ -49,7 +49,7 @@ services:
required: true
- path: docker/.env-local # optional override
required: false
image: postgres:16
image: postgres:17
container_name: superset_db
restart: unless-stopped
volumes:
+1 -1
View File
@@ -76,7 +76,7 @@ services:
required: true
- path: docker/.env-local # optional override
required: false
image: postgres:16
image: postgres:17
restart: unless-stopped
ports:
- "127.0.0.1:${DATABASE_PORT:-5432}:5432"
+1 -1
View File
@@ -1 +1 @@
v20.18.3
v20.20.0
@@ -24,132 +24,180 @@ under the License.
# Contribution Types
To facilitate the development of extensions, we define a set of well-defined contribution types that extensions can implement. These contribution types serve as the building blocks for extensions, allowing them to interact with the host application and provide new functionality.
Extensions provide functionality through **contributions** - well-defined extension points that integrate with the host application.
## Frontend
## Why Contributions?
Frontend contribution types allow extensions to extend Superset's user interface with new views, commands, and menu items.
The contribution system provides several key benefits:
- **Transparency**: Administrators can review exactly what functionality an extension provides before installation. The `manifest.json` documents all REST APIs, MCP tools, views, and other contributions in a single, readable location.
- **Security**: Only contributions explicitly declared in the manifest are registered during startup. Extensions cannot expose functionality they haven't declared, preventing hidden or undocumented code from executing.
- **Discoverability**: The manifest serves as a contract between extensions and the host application, making it easy to understand what an extension does without reading its source code.
## How Contributions Work
Contributions are automatically discovered from source code at build time. Simply use the `@extension_api`, `@tool`, `@prompt` decorators in Python or `define*()` functions in TypeScript - the build system finds them and generates a `manifest.json` with all discovered contributions.
No manual configuration needed!
## Backend Contributions
### REST API Endpoints
Register REST APIs under `/api/v1/extensions/`:
```python
from superset_core.api import RestApi, extension_api
from flask_appbuilder import expose
@extension_api(id="my_api", name="My Extension API")
class MyExtensionAPI(RestApi):
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={"message": "Hello"})
```
### MCP Tools
Register MCP tools for AI agents:
```python
from superset_core.mcp import tool
@tool(tags=["database"])
def query_database(sql: str, database_id: int) -> dict:
"""Execute a SQL query against a database."""
return execute_query(sql, database_id)
```
### MCP Prompts
Register MCP prompts:
```python
from superset_core.mcp import prompt
@prompt(tags={"analysis"})
async def analyze_data(ctx, dataset: str) -> str:
"""Generate analysis for a dataset."""
return f"Analyze the {dataset} dataset..."
```
See [MCP Integration](./mcp) for more details.
## Frontend Contributions
### Views
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Each view is registered with a unique ID and can be activated or deactivated as needed. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
Add panels or views to the UI using `defineView()`:
```json
"frontend": {
"contributions": {
"views": {
"sqllab": {
"panels": [
{
"id": "my_extension.main",
"name": "My Panel Name"
}
]
}
}
}
}
```tsx
import React from 'react';
import { defineView } from '@apache-superset/core';
import MyPanel from './MyPanel';
export const myView = defineView({
id: 'main',
title: 'My Panel',
location: 'sqllab.panels', // or dashboard.tabs, explore.panels, etc.
component: () => <MyPanel />,
});
```
### Commands
Extensions can define custom commands that can be executed within the host application, such as context-aware actions or menu options. Each command can specify properties like a unique command identifier, an icon, a title, and a description. These commands can be invoked by users through menus, keyboard shortcuts, or other UI elements, enabling extensions to add rich, interactive functionality to Superset.
Define executable commands using `defineCommand()`:
```json
"frontend": {
"contributions": {
"commands": [
{
"command": "my_extension.copy_query",
"icon": "CopyOutlined",
"title": "Copy Query",
"description": "Copy the current query to clipboard"
}
]
}
}
```tsx
import { defineCommand } from '@apache-superset/core';
export const copyQuery = defineCommand({
id: 'copy_query',
title: 'Copy Query',
icon: 'CopyOutlined',
execute: async () => {
// Copy the current query
navigator.clipboard.writeText(getCurrentQuery());
},
});
```
### Menus
Extensions can contribute new menu items or context menus to the host application, providing users with additional actions and options. Each menu item can specify properties such as the target view, the command to execute, its placement (primary, secondary, or context), and conditions for when it should be displayed. Menu contribution areas are uniquely identified (e.g., `sqllab.editor` for the SQL Lab editor), allowing extensions to seamlessly integrate their functionality into specific menus and workflows within Superset.
Add items to menus using `defineMenu()`:
```tsx
import { defineMenu } from '@apache-superset/core';
export const contextMenu = defineMenu({
id: 'clear_editor',
title: 'Clear Editor',
location: 'sqllab.editor.context',
action: () => {
clearEditor();
},
});
```
### Editors
Replace the default text editor using `defineEditor()`:
```tsx
import React from 'react';
import { defineEditor } from '@apache-superset/core';
import MonacoEditor from './MonacoEditor';
export const monacoSqlEditor = defineEditor({
id: 'monaco_sql',
name: 'Monaco SQL Editor',
mimeTypes: ['text/x-sql'],
component: MonacoEditor,
});
```
All contributions are automatically discovered at build time and registered at runtime - no manual configuration needed!
## Configuration
### extension.json
Specify which files to scan for contributions:
```json
"frontend": {
"contributions": {
"menus": {
"sqllab": {
"editor": {
"primary": [
{
"view": "builtin.editor",
"command": "my_extension.copy_query"
}
],
"secondary": [
{
"view": "builtin.editor",
"command": "my_extension.prettify"
}
],
"context": [
{
"view": "builtin.editor",
"command": "my_extension.clear"
}
]
}
}
{
"id": "my_extension",
"name": "My Extension",
"version": "1.0.0",
"backend": {
"entryPoints": ["my_extension.entrypoint"],
"files": ["backend/src/**/*.py"]
},
"frontend": {
"moduleFederation": {
"exposes": ["./index"]
}
}
}
```
### Editors
### Manual Contributions (Advanced)
Extensions can replace Superset's default text editors with custom implementations. This enables enhanced editing experiences using alternative editor frameworks like Monaco, CodeMirror, or custom solutions. When an extension registers an editor for a language, it replaces the default Ace editor in all locations that use that language (SQL Lab, Dashboard Properties, CSS editors, etc.).
Override auto-discovery by specifying contributions directly:
```json
"frontend": {
"contributions": {
"editors": [
{
"id": "my_extension.monaco_sql",
"name": "Monaco SQL Editor",
"languages": ["sql"],
"description": "Monaco-based SQL editor with IntelliSense"
}
]
{
"backend": {
"contributions": {
"mcpTools": [
{ "id": "query_db", "name": "query_db", "module": "my_ext.tools.query_db" }
],
"restApis": [
{ "id": "my_api", "name": "My API", "module": "my_ext.api.MyAPI", "basePath": "/my_api" }
]
}
}
}
```
See [Editors Extension Point](./extension-points/editors) for implementation details.
## Backend
Backend contribution types allow extensions to extend Superset's server-side capabilities with new API endpoints, MCP tools, and MCP prompts.
### REST API Endpoints
Extensions can register custom REST API endpoints under the `/api/v1/extensions/` namespace. This dedicated namespace prevents conflicts with built-in endpoints and provides a clear separation between core and extension functionality.
```json
"backend": {
"entryPoints": ["my_extension.entrypoint"],
"files": ["backend/src/my_extension/**/*.py"]
}
```
The entry point module registers the API with Superset:
```python
from superset_core.api.rest_api import add_extension_api
from .api import MyExtensionAPI
add_extension_api(MyExtensionAPI)
```
### MCP Tools and Prompts
Extensions can contribute Model Context Protocol (MCP) tools and prompts that AI agents can discover and use. See [MCP Integration](./mcp) for detailed documentation.
+27 -17
View File
@@ -40,10 +40,10 @@ superset-extensions bundle: Packages the extension into a .supx file.
superset-extensions dev: Automatically rebuilds the extension as files change.
```
When creating a new extension with `superset-extensions init <extension-name>`, the CLI generates a standardized folder structure:
When creating a new extension with `superset-extensions init`, the CLI generates a standardized folder structure:
```
dataset_references/
dataset-references/
├── extension.json
├── frontend/
│ ├── src/
@@ -52,25 +52,33 @@ dataset_references/
│ └── package.json
├── backend/
│ ├── src/
└── dataset_references/
└── superset_extensions/
│ │ └── dataset_references/
│ ├── tests/
│ ├── pyproject.toml
│ └── requirements.txt
├── dist/
│ ├── manifest.json
│ ├── frontend
└── dist/
├── remoteEntry.d7a9225d042e4ccb6354.js
└── 900.038b20cdff6d49cfa8d9.js
└── dist/
├── remoteEntry.d7a9225d042e4ccb6354.js
└── 900.038b20cdff6d49cfa8d9.js
│ └── backend
│ └── dataset_references/
── __init__.py
├── api.py
└── entrypoint.py
├── dataset_references-1.0.0.supx
│ └── superset_extensions/
── dataset_references/
├── __init__.py
├── api.py
│ └── entrypoint.py
├── dataset-references-1.0.0.supx
└── README.md
```
**Note**: The extension ID (`dataset-references`) serves as the basis for all technical names:
- Directory name: `dataset-references` (kebab-case)
- Backend Python package: `dataset_references` (snake_case)
- Frontend package name: `dataset-references` (kebab-case)
- Module Federation name: `datasetReferences` (camelCase)
The `extension.json` file serves as the declared metadata for the extension, containing the extension's name, version, author, description, and a list of capabilities. This file is essential for the host application to understand how to load and manage the extension.
The `frontend` directory contains the source code for the frontend components of the extension, including React components, styles, and assets. The `webpack.config.js` file is used to configure Webpack for building the frontend code, while the `tsconfig.json` file defines the TypeScript configuration for the project. The `package.json` file specifies the dependencies and scripts for building and testing the frontend code.
@@ -87,7 +95,8 @@ The `extension.json` file contains all metadata necessary for the host applicati
```json
{
"name": "dataset_references",
"id": "dataset-references",
"name": "Dataset References",
"version": "1.0.0",
"frontend": {
"contributions": {
@@ -95,20 +104,21 @@ The `extension.json` file contains all metadata necessary for the host applicati
"sqllab": {
"panels": [
{
"id": "dataset_references.main",
"name": "Dataset references"
"id": "dataset-references.main",
"name": "Dataset References"
}
]
}
}
},
"moduleFederation": {
"exposes": ["./index"]
"exposes": ["./index"],
"name": "datasetReferences"
}
},
"backend": {
"entryPoints": ["dataset_references.entrypoint"],
"files": ["backend/src/dataset_references/**/*.py"]
"entryPoints": ["superset_extensions.dataset_references.entrypoint"],
"files": ["backend/src/superset_extensions/dataset_references/**/*.py"]
}
}
```
@@ -172,13 +172,9 @@ import { editors } from '@apache-superset/core';
import MonacoSQLEditor from './MonacoSQLEditor';
export function activate(context) {
// Register the Monaco editor for SQL
// Register the Monaco editor for SQL using the contribution ID from extension.json
const disposable = editors.registerEditorProvider(
{
id: 'monaco-sql-editor.sql',
name: 'Monaco SQL Editor',
languages: ['sql'],
},
'monaco-sql-editor.sql',
MonacoSQLEditor,
);
+76 -34
View File
@@ -51,27 +51,39 @@ Use the CLI to scaffold a new extension project. Extensions can include frontend
superset-extensions init
```
The CLI will prompt you for information:
The CLI will prompt you for information using a three-step publisher workflow:
```
Extension ID (unique identifier, alphanumeric only): hello_world
Extension name (human-readable display name): Hello World
Extension display name: Hello World
Extension name (hello-world): hello-world
Publisher (e.g., my-org): my-org
Initial version [0.1.0]: 0.1.0
License [Apache-2.0]: Apache-2.0
Include frontend? [Y/n]: Y
Include backend? [Y/n]: Y
```
**Publisher Namespaces**: Extensions use organizational namespaces similar to VS Code extensions, providing collision-safe naming across organizations:
- **NPM package**: `@my-org/hello-world` (scoped package for frontend distribution)
- **Module Federation name**: `myOrg_helloWorld` (collision-safe JavaScript identifier)
- **Backend package**: `my_org-hello_world` (collision-safe Python distribution name)
- **Python namespace**: `superset_extensions.my_org.hello_world`
This approach ensures that extensions from different organizations cannot conflict, even if they use the same technical name (e.g., both `acme.dashboard-widgets` and `corp.dashboard-widgets` can coexist).
This creates a complete project structure:
```
hello_world/
my-org.hello-world/
├── extension.json # Extension metadata and configuration
├── backend/ # Backend Python code
│ ├── src/
│ │ └── hello_world/
│ │ ── __init__.py
│ │ └── entrypoint.py # Backend registration
│ │ └── superset_extensions/
│ │ ── my_org/
│ │ ├── __init__.py
│ │ └── hello_world/
│ │ ├── __init__.py
│ │ └── entrypoint.py # Backend registration
│ └── pyproject.toml
└── frontend/ # Frontend TypeScript/React code
├── src/
@@ -87,8 +99,9 @@ The generated `extension.json` contains basic metadata. Update it to register yo
```json
{
"id": "hello_world",
"name": "Hello World",
"publisher": "my-org",
"name": "hello-world",
"displayName": "Hello World",
"version": "0.1.0",
"license": "Apache-2.0",
"frontend": {
@@ -97,7 +110,7 @@ The generated `extension.json` contains basic metadata. Update it to register yo
"sqllab": {
"panels": [
{
"id": "hello_world.main",
"id": "my-org.hello-world.main",
"name": "Hello World"
}
]
@@ -105,27 +118,33 @@ The generated `extension.json` contains basic metadata. Update it to register yo
}
},
"moduleFederation": {
"exposes": ["./index"]
"exposes": ["./index"],
"name": "myOrg_helloWorld"
}
},
"backend": {
"entryPoints": ["hello_world.entrypoint"],
"files": ["backend/src/hello_world/**/*.py"]
"entryPoints": ["superset_extensions.my_org.hello_world.entrypoint"],
"files": ["backend/src/superset_extensions/my_org/hello_world/**/*.py"]
},
"permissions": ["can_read"]
}
```
**Note**: The `moduleFederation.name` uses collision-safe naming (`myOrg_helloWorld`), and backend entry points use the full nested Python namespace (`superset_extensions.my_org.hello_world`).
**Key fields:**
- `publisher`: Organizational namespace for the extension
- `name`: Technical identifier (kebab-case)
- `displayName`: Human-readable name shown to users
- `frontend.contributions.views.sqllab.panels`: Registers your panel in SQL Lab
- `backend.entryPoints`: Python modules to load eagerly when extension starts
## Step 4: Create Backend API
The CLI generated a basic `backend/src/hello_world/entrypoint.py`. We'll create an API endpoint.
The CLI generated a basic `backend/src/superset_extensions/my_org/hello_world/entrypoint.py`. We'll create an API endpoint.
**Create `backend/src/hello_world/api.py`**
**Create `backend/src/superset_extensions/my_org/hello_world/api.py`**
```python
from flask import Response
@@ -174,10 +193,10 @@ class HelloWorldAPI(RestApi):
- Extends `RestApi` from `superset_core.api.types.rest_api`
- Uses Flask-AppBuilder decorators (`@expose`, `@protect`, `@safe`)
- Returns responses using `self.response(status_code, result=data)`
- The endpoint will be accessible at `/extensions/hello_world/message`
- The endpoint will be accessible at `/extensions/my-org/hello-world/message`
- OpenAPI docstrings are crucial - Flask-AppBuilder uses them to automatically generate interactive API documentation at `/swagger/v1`, allowing developers to explore endpoints, understand schemas, and test the API directly from the browser
**Update `backend/src/hello_world/entrypoint.py`**
**Update `backend/src/superset_extensions/my_org/hello_world/entrypoint.py`**
Replace the generated print statement with API registration:
@@ -201,7 +220,7 @@ The `@apache-superset/core` package must be listed in both `peerDependencies` (t
```json
{
"name": "hello_world",
"name": "@my-org/hello-world",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
@@ -252,7 +271,7 @@ module.exports = (env, argv) => {
chunkFilename: "[name].[contenthash].js",
clean: true,
path: path.resolve(__dirname, "dist"),
publicPath: `/api/v1/extensions/${packageConfig.name}/`,
publicPath: `/api/v1/extensions/my-org/hello-world/`,
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
@@ -273,7 +292,7 @@ module.exports = (env, argv) => {
},
plugins: [
new ModuleFederationPlugin({
name: packageConfig.name,
name: "myOrg_helloWorld",
filename: "remoteEntry.[contenthash].js",
exposes: {
"./index": "./src/index.tsx",
@@ -330,7 +349,7 @@ const HelloWorldPanel: React.FC = () => {
const fetchMessage = async () => {
try {
const csrfToken = await authentication.getCSRFToken();
const response = await fetch('/extensions/hello_world/message', {
const response = await fetch('/extensions/my-org/hello-world/message', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -398,25 +417,48 @@ Replace the generated code with the extension entry point:
```tsx
import React from 'react';
import { core } from '@apache-superset/core';
import { defineView } from '@apache-superset/core';
import HelloWorldPanel from './HelloWorldPanel';
export const activate = (context: core.ExtensionContext) => {
context.disposables.push(
core.registerViewProvider('hello_world.main', () => <HelloWorldPanel />),
);
};
// Define the view - automatically registered when extension loads
export const helloWorldView = defineView({
id: 'main',
title: 'Hello World',
location: 'sqllab.panels',
component: () => <HelloWorldPanel />,
});
```
export const deactivate = () => {};
That's it! For most extensions, this is all you need.
**Optional lifecycle callbacks:**
If you need to run code when your contribution activates or deactivates, add optional callbacks:
```tsx
export const helloWorldView = defineView({
id: 'main',
title: 'Hello World',
location: 'sqllab.panels',
component: () => <HelloWorldPanel />,
onActivate: () => {
// Optional: runs when panel is registered
console.log('Hello World panel activated');
},
onDeactivate: () => {
// Optional: runs when panel is unregistered
console.log('Hello World panel deactivated');
},
});
```
**Key patterns:**
- `activate` function is called when the extension loads
- `core.registerViewProvider` registers the component with ID `hello_world.main` (matching `extension.json`)
- `defineView()` automatically handles discovery, registration, and cleanup
- `onActivate` and `onDeactivate` are completely optional
- `authentication.getCSRFToken()` retrieves the CSRF token for API calls
- Fetch calls to `/extensions/{extension_id}/{endpoint}` reach your backend API
- `context.disposables.push()` ensures proper cleanup
- Everything happens automatically - no manual setup required
## Step 6: Install Dependencies
@@ -444,7 +486,7 @@ This command automatically:
- `manifest.json` - Build metadata and asset references
- `frontend/dist/` - Built frontend assets (remoteEntry.js, chunks)
- `backend/` - Python source files
- Packages everything into `hello_world-0.1.0.supx` - a zip archive with the specific structure required by Superset
- Packages everything into `my-org.hello-world-0.1.0.supx` - a zip archive with the specific structure required by Superset
## Step 8: Deploy to Superset
@@ -469,7 +511,7 @@ EXTENSIONS_PATH = "/path/to/extensions/folder"
Copy your `.supx` file to the configured extensions path:
```bash
cp hello_world-0.1.0.supx /path/to/extensions/folder/
cp my-org.hello-world-0.1.0.supx /path/to/extensions/folder/
```
**Restart Superset**
@@ -500,7 +542,7 @@ Here's what happens when your extension loads:
4. **Module Federation**: Webpack loads your extension code and resolves `@apache-superset/core` to `window.superset`
5. **Activation**: `activate()` is called, registering your view provider
6. **Rendering**: When the user opens your panel, React renders `<HelloWorldPanel />`
7. **API call**: Component fetches data from `/extensions/hello_world/message`
7. **API call**: Component fetches data from `/extensions/my-org/hello-world/message`
8. **Backend response**: Your Flask API returns the hello world message
9. **Display**: Component shows the message to the user
@@ -141,10 +141,10 @@ database engine on a separate host or container.
Superset supports the following database engines/versions:
| Database Engine | Supported Versions |
| ----------------------------------------- | ---------------------------------------- |
| [PostgreSQL](https://www.postgresql.org/) | 10.X, 11.X, 12.X, 13.X, 14.X, 15.X, 16.X |
| [MySQL](https://www.mysql.com/) | 5.7, 8.X |
| Database Engine | Supported Versions |
| ----------------------------------------- | ---------------------------------------------- |
| [PostgreSQL](https://www.postgresql.org/) | 10.X, 11.X, 12.X, 13.X, 14.X, 15.X, 16.X, 17.X |
| [MySQL](https://www.mysql.com/) | 5.7, 8.X |
Use the following database drivers and connection strings:
@@ -96,6 +96,24 @@ To enable this entry, add the following line to the `.env` file:
SUPERSET_FEATURE_EMBEDDED_SUPERSET=true
```
### Hiding the Logout Button in Embedded Contexts
When Superset is embedded in an application that manages authentication via SSO (OAuth2, SAML, or JWT), the logout button should be hidden since session management is handled by the parent application.
To hide the logout button in embedded contexts, add to `superset_config.py`:
```python
FEATURE_FLAGS = {
"DISABLE_EMBEDDED_SUPERSET_LOGOUT": True,
}
```
This flag only hides the logout button when Superset detects it is running inside an iframe. Users accessing Superset directly (not embedded) will still see the logout button regardless of this setting.
:::note
When embedding with SSO, also set `SESSION_COOKIE_SAMESITE = 'None'` and `SESSION_COOKIE_SECURE = True`. See [Security documentation](/docs/security/securing_superset) for details.
:::
## CSRF settings
Similarly, [flask-wtf](https://flask-wtf.readthedocs.io/en/0.15.x/config/) is used to manage
+18 -17
View File
@@ -48,25 +48,26 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^8.6.15",
"@storybook/addon-docs": "^8.6.17",
"@storybook/blocks": "^8.6.15",
"@storybook/channels": "^8.6.15",
"@storybook/client-logger": "^8.6.15",
"@storybook/components": "^8.6.15",
"@storybook/core": "^8.6.15",
"@storybook/core-events": "^8.6.15",
"@storybook/channels": "^8.6.17",
"@storybook/client-logger": "^8.6.17",
"@storybook/components": "^8.6.17",
"@storybook/core": "^8.6.17",
"@storybook/core-events": "^8.6.17",
"@storybook/csf": "^0.1.13",
"@storybook/docs-tools": "^8.6.15",
"@storybook/preview-api": "^8.6.15",
"@storybook/docs-tools": "^8.6.17",
"@storybook/preview-api": "^8.6.17",
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.11",
"antd": "^6.3.0",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001769",
"@swc/core": "^1.15.13",
"antd": "^6.3.1",
"baseline-browser-mapping": "^2.10.0",
"caniuse-lite": "^1.0.30001770",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
"js-yaml": "^4.1.1",
@@ -81,8 +82,8 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.1.1",
"storybook": "^8.6.15",
"swagger-ui-react": "^5.31.0",
"storybook": "^8.6.17",
"swagger-ui-react": "^5.31.2",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"unist-util-visit": "^5.1.0"
@@ -94,7 +95,7 @@
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.55.0",
"@typescript-eslint/parser": "^8.56.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
@@ -102,8 +103,8 @@
"globals": "^17.3.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.55.0",
"webpack": "^5.105.1"
"typescript-eslint": "^8.56.1",
"webpack": "^5.105.2"
},
"browserslist": {
"production": [
@@ -104,6 +104,10 @@ const DatabasePage: React.FC<DatabasePageProps> = ({ database, name }) => {
</div>
);
// Ensure db filename can be obtained regardless of how db doc gets generated
// by either Flask app (superset.db_engine_specs.postgres) or fallback mode (postgres)
const databaseModuleFilename = `${database.module?.split('.').pop()}.py`;
// Render driver information
const renderDrivers = () => {
if (!docs?.drivers?.length) return null;
@@ -770,11 +774,11 @@ const DatabasePage: React.FC<DatabasePageProps> = ({ database, name }) => {
Help improve this documentation by editing the engine spec:
</Text>
<a
href={`https://github.com/apache/superset/edit/master/superset/db_engine_specs/${database.module}.py`}
href={`https://github.com/apache/superset/edit/master/superset/db_engine_specs/${databaseModuleFilename}`}
target="_blank"
rel="noreferrer"
>
<EditOutlined /> Edit {database.module}.py
<EditOutlined /> Edit {databaseModuleFilename}
</a>
</Space>
</Card>
+163 -7
View File
@@ -1,16 +1,16 @@
{
"generated": "2026-01-31T10:47:01.730Z",
"generated": "2026-02-16T04:47:37.257Z",
"statistics": {
"totalDatabases": 70,
"withDocumentation": 70,
"withConnectionString": 70,
"totalDatabases": 72,
"withDocumentation": 72,
"withConnectionString": 72,
"withDrivers": 36,
"withAuthMethods": 4,
"supportsJoins": 66,
"supportsSubqueries": 67,
"supportsJoins": 68,
"supportsSubqueries": 69,
"supportsDynamicSchema": 15,
"supportsCatalog": 9,
"averageScore": 32,
"averageScore": 31,
"maxScore": 201,
"byCategory": {
"Other Databases": [
@@ -74,6 +74,7 @@
"Apache Kylin",
"Azure Synapse",
"Ocient",
"Apache Phoenix",
"Amazon Redshift",
"RisingWave",
"SingleStore",
@@ -151,12 +152,14 @@
"Greenplum",
"Apache Hive",
"Apache Impala",
"Apache IoTDB",
"Apache Kylin",
"MariaDB",
"MonetDB",
"MySQL",
"OceanBase",
"Parseable",
"Apache Phoenix",
"Apache Pinot",
"PostgreSQL",
"Presto",
@@ -187,6 +190,7 @@
"Time Series Databases": [
"CrateDB",
"Apache Druid",
"Apache IoTDB",
"Apache Pinot",
"TDengine"
],
@@ -197,7 +201,9 @@
"Apache Druid",
"Apache Hive",
"Apache Impala",
"Apache IoTDB",
"Apache Kylin",
"Apache Phoenix",
"Apache Pinot",
"Apache Solr",
"Apache Spark SQL"
@@ -2890,6 +2896,47 @@
"query_cost_estimation": false,
"sql_validation": false
},
"Apache IoTDB": {
"engine": "apache_iotdb",
"engine_name": "Apache IoTDB",
"module": "iotdb",
"documentation": {
"description": "Apache IoTDB is a time series database designed for IoT data, with efficient storage and query capabilities for massive time series data.",
"logo": "apache-iotdb.svg",
"homepage_url": "https://iotdb.apache.org/",
"categories": [
"APACHE_PROJECTS",
"TIME_SERIES",
"OPEN_SOURCE"
],
"pypi_packages": [
"apache-iotdb"
],
"connection_string": "iotdb://{username}:{password}@{hostname}:{port}",
"default_port": 6667,
"parameters": {
"username": "Database username (default: root)",
"password": "Database password (default: root)",
"hostname": "IP address or hostname",
"port": "Default 6667"
},
"notes": "The IoTDB SQLAlchemy dialect was written to integrate with Apache Superset. IoTDB uses a hierarchical data model, which is reorganized into a relational model for SQL queries."
},
"time_grains": {},
"score": 0,
"max_score": 0,
"joins": true,
"subqueries": true,
"supports_dynamic_schema": false,
"supports_catalog": false,
"supports_dynamic_catalog": false,
"ssh_tunneling": false,
"query_cancelation": false,
"supports_file_upload": false,
"user_impersonation": false,
"query_cost_estimation": false,
"sql_validation": false
},
"Azure Data Explorer": {
"engine": "azure_data_explorer",
"engine_name": "Azure Data Explorer",
@@ -4039,6 +4086,41 @@
"query_cost_estimation": false,
"sql_validation": false
},
"Apache Phoenix": {
"engine": "apache_phoenix",
"engine_name": "Apache Phoenix",
"module": "phoenix",
"documentation": {
"description": "Apache Phoenix is a relational database layer over Apache HBase, providing low-latency SQL queries over HBase data.",
"logo": "apache-phoenix.png",
"homepage_url": "https://phoenix.apache.org/",
"categories": [
"APACHE_PROJECTS",
"ANALYTICAL_DATABASES",
"OPEN_SOURCE"
],
"pypi_packages": [
"phoenixdb"
],
"connection_string": "phoenix://{hostname}:{port}/",
"default_port": 8765,
"notes": "Phoenix provides a SQL interface to Apache HBase. The phoenixdb driver connects via the Phoenix Query Server and supports a subset of SQLAlchemy."
},
"time_grains": {},
"score": 0,
"max_score": 0,
"joins": true,
"subqueries": true,
"supports_dynamic_schema": false,
"supports_catalog": false,
"supports_dynamic_catalog": false,
"ssh_tunneling": false,
"query_cancelation": false,
"supports_file_upload": false,
"user_impersonation": false,
"query_cost_estimation": false,
"sql_validation": false
},
"Apache Pinot": {
"engine": "apache_pinot",
"engine_name": "Apache Pinot",
@@ -4207,6 +4289,80 @@
"OPEN_SOURCE"
]
},
{
"name": "Supabase",
"description": "Open-source Firebase alternative built on top of PostgreSQL, providing a full backend-as-a-service with a hosted Postgres database.",
"logo": "supabase.svg",
"homepage_url": "https://supabase.com/",
"pypi_packages": [
"psycopg2"
],
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
"connection_examples": [
{
"description": "Supabase project (connection pooler)",
"connection_string": "postgresql://{username}.{project_ref}:{password}@aws-0-{region}.pooler.supabase.com:6543/{database}"
}
],
"parameters": {
"username": "Database user (default: postgres)",
"password": "Database password",
"host": "Supabase project host (from project settings)",
"port": "Default 5432 (direct) or 6543 (pooler)",
"database": "Database name (default: postgres)",
"project_ref": "Supabase project reference (from project settings)",
"region": "Supabase project region (e.g., us-east-1)"
},
"notes": "Find connection details in your Supabase project dashboard under Settings > Database. Use the connection pooler (port 6543) for better connection management.",
"docs_url": "https://supabase.com/docs/guides/database/connecting-to-postgres",
"categories": [
"HOSTED_OPEN_SOURCE"
]
},
{
"name": "Google AlloyDB",
"description": "Google Cloud's PostgreSQL-compatible database service for demanding transactional and analytical workloads.",
"logo": "alloydb.png",
"homepage_url": "https://cloud.google.com/alloydb",
"pypi_packages": [
"psycopg2"
],
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
"parameters": {
"username": "Database user (default: postgres)",
"password": "Database password",
"host": "AlloyDB instance IP or Auth Proxy address",
"port": "Default 5432",
"database": "Database name"
},
"notes": "For public IP connections, use the AlloyDB Auth Proxy for secure access. Private IP connections can connect directly.",
"docs_url": "https://cloud.google.com/alloydb/docs",
"categories": [
"CLOUD_GCP",
"HOSTED_OPEN_SOURCE"
]
},
{
"name": "Neon",
"description": "Serverless PostgreSQL with branching, scale-to-zero, and bottomless storage.",
"logo": "neon.png",
"homepage_url": "https://neon.tech/",
"pypi_packages": [
"psycopg2"
],
"connection_string": "postgresql://{username}:{password}@{host}/{database}?sslmode=require",
"parameters": {
"username": "Neon role name",
"password": "Neon role password",
"host": "Neon hostname (e.g., ep-cool-name-123456.us-east-2.aws.neon.tech)",
"database": "Database name (default: neondb)"
},
"notes": "SSL is required for all connections. Find connection details in the Neon console under Connection Details.",
"docs_url": "https://neon.tech/docs/connect/connect-from-any-app",
"categories": [
"HOSTED_OPEN_SOURCE"
]
},
{
"name": "Amazon Aurora PostgreSQL",
"description": "Amazon Aurora PostgreSQL is a fully managed, PostgreSQL-compatible relational database with up to 5x the throughput of standard PostgreSQL.",
+8
View File
@@ -261,6 +261,14 @@
"description": "Data panel closed by default in chart builder",
"category": "runtime_config"
},
{
"name": "DISABLE_EMBEDDED_SUPERSET_LOGOUT",
"default": false,
"lifecycle": "stable",
"description": "Hide the logout button in embedded contexts (e.g., when using SSO in iframes)",
"docs": "https://superset.apache.org/docs/configuration/networking-settings#hiding-the-logout-button-in-embedded-contexts",
"category": "runtime_config"
},
{
"name": "DRILL_BY",
"default": true,
+520 -470
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,9 +1,9 @@
dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 13.4.4
version: 16.7.27
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 17.9.4
digest: sha256:c6290bb7e8ce9c694c06b3f5e9b9d01401943b0943c515d3a7a3a8dc1e6492ea
generated: "2025-03-16T00:52:41.47139769+09:00"
digest: sha256:fcae507ca24a20b9cc08b8bf0fcb0eba8ffa33126ab6f71cc3a6e1d5e997e9e3
generated: "2026-02-08T14:11:58.8058368+01:00"
+2 -2
View File
@@ -29,10 +29,10 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.15.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.15.4 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 13.4.4
version: 16.7.27
repository: oci://registry-1.docker.io/bitnamicharts
condition: postgresql.enabled
- name: redis
+2 -2
View File
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.15.3](https://img.shields.io/badge/Version-0.15.3-informational?style=flat-square)
![Version: 0.15.4](https://img.shields.io/badge/Version-0.15.4-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
@@ -50,7 +50,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| Repository | Name | Version |
|------------|------|---------|
| oci://registry-1.docker.io/bitnamicharts | postgresql | 13.4.4 |
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.7.27 |
| oci://registry-1.docker.io/bitnamicharts | redis | 17.9.4 |
## Values
+2 -2
View File
@@ -82,7 +82,7 @@ dependencies = [
"parsedatetime",
"paramiko>=3.4.0",
"pgsanity",
"Pillow>=11.0.0, <12",
"Pillow>=11.0.0, <13",
"polyline>=2.0.0, <3.0",
"pydantic>=2.8.0",
"pyparsing>=3.0.6, <4",
@@ -99,7 +99,7 @@ dependencies = [
"simplejson>=3.15.0",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.3, <0.39",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=28.10.0, <29",
# newer pandas needs 0.9+
"tabulate>=0.9.0, <1.0",
+1 -1
View File
@@ -399,7 +399,7 @@ sqlalchemy==1.4.54
# marshmallow-sqlalchemy
# shillelagh
# sqlalchemy-utils
sqlalchemy-utils==0.38.3
sqlalchemy-utils==0.42.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
+1 -1
View File
@@ -990,7 +990,7 @@ sqlalchemy==1.4.54
# sqlalchemy-utils
sqlalchemy-bigquery==1.15.0
# via apache-superset
sqlalchemy-utils==0.38.3
sqlalchemy-utils==0.42.0
# via
# -c requirements/base-constraint.txt
# apache-superset
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
"flask-appbuilder>=5.0.2,<6",
"pydantic>=2.8.0",
"sqlalchemy>=1.4.0,<2.0",
"sqlalchemy-utils>=0.38.0",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=28.10.0, <29",
"typing-extensions>=4.0.0",
]
+190 -22
View File
@@ -16,20 +16,69 @@
# under the License.
"""
REST API functions for superset-core.
REST API decorators and base classes for superset-core.
Provides dependency-injected REST API utility functions that will be replaced by
host implementations during initialization.
Provides decorator stubs that will be replaced by host implementations
during initialization.
Usage:
from superset_core.api.rest_api import add_api, add_extension_api
from superset_core.api import RestApi, api, extension_api
add_api(MyCustomAPI)
add_extension_api(MyExtensionAPI)
# For host application APIs
@api
class MyAPI(RestApi):
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={})
# For extension APIs (auto-discovered, registered under /extensions/)
@extension_api(id="my_api", name="My Extension API")
class MyExtensionAPI(RestApi):
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={})
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, TypeVar
from flask_appbuilder.api import BaseApi
T = TypeVar("T", bound=type)
# =============================================================================
# Metadata dataclass - attached to decorated classes for discovery
# =============================================================================
@dataclass
class RestApiMetadata:
"""
Metadata stored on classes decorated with @extension_api.
Attached to classes as __rest_api_metadata__ for build-time discovery.
Includes auto-inferred Flask-AppBuilder configuration fields.
"""
id: str
name: str
description: str | None = None
base_path: str = "" # Defaults to /{id}
module: str = "" # Format: "package.module.ClassName"
# Auto-inferred Flask-AppBuilder fields
resource_name: str = "" # Used for URL generation and permissions
openapi_spec_tag: str = "" # Used for OpenAPI documentation grouping
class_permission_name: str = "" # Used for RBAC permissions
# =============================================================================
# Base class
# =============================================================================
class RestApi(BaseApi):
"""
@@ -42,31 +91,150 @@ class RestApi(BaseApi):
allow_browser_login = True
def add_api(api: type[RestApi]) -> None:
# =============================================================================
# Decorator stubs - replaced by host application during initialization
# =============================================================================
def api(cls: T) -> T:
"""
Add a REST API to the Superset API.
Decorator to register a REST API with the host application.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
This is a stub that raises NotImplementedError until the host application
initializes the concrete implementation via dependency injection.
:param api: A REST API instance.
:returns: None.
Usage:
@api
class MyAPI(RestApi):
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={})
Args:
cls: The API class to register
Returns:
The decorated class
Raises:
NotImplementedError: Before host implementation is initialized
"""
raise NotImplementedError("Function will be replaced during initialization")
raise NotImplementedError(
"REST API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
def add_extension_api(api: type[RestApi]) -> None:
def extension_api(
id: str, # noqa: A002
name: str,
description: str | None = None,
base_path: str | None = None,
resource_name: str | None = None,
openapi_spec_tag: str | None = None,
class_permission_name: str | None = None,
) -> Callable[[T], T]:
"""
Add an extension REST API to the Superset API.
Decorator to mark a class as an extension REST API.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
This is a stub that raises NotImplementedError until the host application
initializes the concrete implementation via dependency injection.
:param api: An extension REST API instance. These are placed under
the /extensions resource.
:returns: None.
In BUILD mode, stores metadata for discovery without registration.
Auto-infers Flask-AppBuilder fields from decorator parameters:
- resource_name: defaults to id (lowercase)
- openapi_spec_tag: defaults to name
- class_permission_name: defaults to resource_name
- base_path: defaults to /{id}
Extension APIs are:
- Auto-discovered at build time
- Registered under /api/v1/extensions/{id}/
- Subject to manifest validation for security
Usage:
@extension_api(id="my_api", name="My Extension API")
class MyExtensionAPI(RestApi):
# These are auto-set by the decorator:
# resource_name = "my_api"
# openapi_spec_tag = "My Extension API"
# class_permission_name = "my_api"
@expose("/endpoint", methods=["GET"])
def get_data(self):
return self.response(200, result={})
Args:
id: Unique identifier for this API (used in URL path and resource_name)
name: Human-readable name for the API (used for openapi_spec_tag)
description: Description of the API (defaults to class docstring)
base_path: Base URL path (defaults to /{id})
resource_name: Override resource_name (defaults to id)
openapi_spec_tag: Override OpenAPI tag (defaults to name)
class_permission_name: Override permission name (defaults to resource_name)
Returns:
Decorator that attaches __rest_api_metadata__ and auto-configures
Flask-AppBuilder fields
Raises:
NotImplementedError: Before host implementation is initialized (except in
BUILD mode)
"""
raise NotImplementedError("Function will be replaced during initialization")
def decorator(cls: T) -> T:
# Auto-infer Flask-AppBuilder fields
inferred_resource_name = resource_name or id.lower()
inferred_openapi_spec_tag = openapi_spec_tag or name
inferred_class_permission_name = class_permission_name or inferred_resource_name
inferred_base_path = base_path or f"/{id}"
# Set Flask-AppBuilder attributes on the class
cls.resource_name = inferred_resource_name # type: ignore[attr-defined]
cls.openapi_spec_tag = inferred_openapi_spec_tag # type: ignore[attr-defined]
cls.class_permission_name = inferred_class_permission_name # type: ignore[attr-defined]
# Try to get context for BUILD mode detection
try:
from superset_core.extensions.context import get_context
ctx = get_context()
# In BUILD mode, store metadata for discovery
if ctx.is_build_mode:
api_description = description
if api_description is None and cls.__doc__:
api_description = cls.__doc__.strip().split("\n")[0]
metadata = RestApiMetadata(
id=id,
name=name,
description=api_description,
base_path=inferred_base_path,
module=f"{cls.__module__}.{cls.__name__}",
resource_name=inferred_resource_name,
openapi_spec_tag=inferred_openapi_spec_tag,
class_permission_name=inferred_class_permission_name,
)
cls.__rest_api_metadata__ = metadata # type: ignore[attr-defined]
return cls
except ImportError:
# Context not available - fall through to error
pass
# Default behavior: raise error for host to replace
raise NotImplementedError(
"Extension REST API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
return decorator
__all__ = ["RestApi", "add_api", "add_extension_api"]
__all__ = [
"RestApi",
"RestApiMetadata",
"api",
"extension_api",
]
@@ -14,3 +14,51 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Extension framework types and context."""
from superset_core.extensions.context import (
ContributionType,
get_context,
PendingContribution,
RegistrationContext,
RegistrationMode,
)
from superset_core.extensions.types import (
BackendContributions,
ExtensionConfig,
ExtensionConfigBackend,
ExtensionConfigFrontend,
FrontendContributions,
Manifest,
ManifestBackend,
ManifestFrontend,
McpPromptContribution,
McpToolContribution,
ModuleFederationConfig,
RestApiContribution,
)
__all__ = [
# Context
"ContributionType",
"get_context",
"PendingContribution",
"RegistrationContext",
"RegistrationMode",
# Types - Config
"ExtensionConfig",
"ExtensionConfigBackend",
"ExtensionConfigFrontend",
# Types - Manifest
"Manifest",
"ManifestBackend",
"ManifestFrontend",
# Types - Contributions
"BackendContributions",
"FrontendContributions",
"McpToolContribution",
"McpPromptContribution",
"RestApiContribution",
"ModuleFederationConfig",
]
@@ -0,0 +1,35 @@
# 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.
"""
Constants for extension validation and naming.
"""
# Publisher validation pattern: lowercase letters, numbers, hyphens; must start with
# letter; no consecutive hyphens or trailing hyphens
PUBLISHER_PATTERN = r"^[a-z]([a-z0-9]*(-[a-z0-9]+)*)?$"
# Technical name validation pattern: lowercase letters, numbers, hyphens; must start
# with letter; no consecutive hyphens or trailing hyphens
TECHNICAL_NAME_PATTERN = r"^[a-z]([a-z0-9]*(-[a-z0-9]+)*)?$"
# Display name validation pattern: must start with letter, can contain letters,
# numbers, spaces, hyphens, underscores, dots
DISPLAY_NAME_PATTERN = r"^[a-zA-Z][a-zA-Z0-9\s\-_\.]*$"
# Version pattern for semantic versioning
VERSION_PATTERN = r"^\d+\.\d+\.\d+$"
@@ -0,0 +1,197 @@
# 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.
"""
Registration context for contribution discovery and security.
Controls how decorators behave based on the current execution context:
- host: Register immediately (for host application components)
- extension: Store metadata only, defer to ExtensionManager (security boundary)
- build: Store metadata only, for CLI discovery
The manifest.json serves as the security allowlist for extensions.
Only contributions declared in the manifest will be registered.
"""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Iterator, Literal, TYPE_CHECKING
if TYPE_CHECKING:
from superset_core.api.rest_api import RestApiMetadata
from superset_core.mcp import PromptMetadata, ToolMetadata
# Type alias for contribution types
ContributionType = Literal["tool", "prompt", "restApi"]
class RegistrationMode(Enum):
"""Registration modes for decorator behavior."""
HOST = "host" # Register immediately (host application)
EXTENSION = "extension" # Defer registration (manifest validation)
BUILD = "build" # Metadata only (CLI discovery)
@dataclass
class PendingContribution:
"""A contribution waiting for registration after manifest validation."""
func: Callable[..., Any]
metadata: ToolMetadata | PromptMetadata | RestApiMetadata
contrib_type: ContributionType
@dataclass
class RegistrationContext:
"""
Global context controlling decorator registration behavior.
In host mode, decorators register immediately with MCP.
In extension mode, decorators store metadata and the ExtensionManager
validates against the manifest before completing registration.
In build mode, decorators only store metadata for discovery.
"""
_mode: RegistrationMode = RegistrationMode.HOST
_current_extension_id: str | None = None
_pending_contributions: dict[str, list[PendingContribution]] = field(
default_factory=dict
)
def set_mode(self, mode: RegistrationMode) -> None:
"""Set the global registration mode."""
self._mode = mode
@property
def mode(self) -> RegistrationMode:
"""Get the current registration mode."""
return self._mode
@property
def is_host_mode(self) -> bool:
"""True if in host mode (immediate registration)."""
return self._mode == RegistrationMode.HOST
@property
def is_extension_mode(self) -> bool:
"""True if in extension mode (deferred registration)."""
return self._mode == RegistrationMode.EXTENSION
@property
def is_build_mode(self) -> bool:
"""True if in build mode (metadata only)."""
return self._mode == RegistrationMode.BUILD
@property
def current_extension_id(self) -> str | None:
"""Get the current extension ID being loaded."""
return self._current_extension_id
@contextmanager
def extension_context(self, extension_id: str) -> Iterator[None]:
"""
Context manager for loading an extension.
While in this context, decorators defer registration and store
contributions for manifest validation.
Args:
extension_id: The extension being loaded
Yields:
None
"""
old_mode = self._mode
old_ext = self._current_extension_id
self._mode = RegistrationMode.EXTENSION
self._current_extension_id = extension_id
self._pending_contributions[extension_id] = []
try:
yield
finally:
self._mode = old_mode
self._current_extension_id = old_ext
def add_pending_contribution(
self,
func: Callable[..., Any],
metadata: ToolMetadata | PromptMetadata | RestApiMetadata,
contrib_type: ContributionType,
) -> None:
"""
Add a contribution pending manifest validation.
Called by decorators in extension mode.
Args:
func: The decorated function
metadata: The contribution metadata
contrib_type: Type of contribution ("tool", "prompt", "restApi")
"""
if self._current_extension_id is None:
raise RuntimeError(
"Cannot add pending contribution outside extension context"
)
self._pending_contributions[self._current_extension_id].append(
PendingContribution(
func=func,
metadata=metadata,
contrib_type=contrib_type,
)
)
def get_pending_contributions(self, extension_id: str) -> list[PendingContribution]:
"""
Get pending contributions for an extension.
Called by ExtensionManager during manifest validation.
Args:
extension_id: The extension to get contributions for
Returns:
List of pending contributions
"""
return self._pending_contributions.get(extension_id, [])
def clear_pending_contributions(self, extension_id: str) -> None:
"""
Clear pending contributions after registration.
Called by ExtensionManager after successful registration.
Args:
extension_id: The extension to clear contributions for
"""
self._pending_contributions.pop(extension_id, None)
# Global singleton instance
_context = RegistrationContext()
def get_context() -> RegistrationContext:
"""Get the global registration context."""
return _context
@@ -29,6 +29,13 @@ from typing import Any
from pydantic import BaseModel, Field # noqa: I001
from superset_core.extensions.constants import (
DISPLAY_NAME_PATTERN,
PUBLISHER_PATTERN,
TECHNICAL_NAME_PATTERN,
VERSION_PATTERN,
)
# =============================================================================
# Shared components
# =============================================================================
@@ -37,6 +44,11 @@ from pydantic import BaseModel, Field # noqa: I001
class ModuleFederationConfig(BaseModel):
"""Configuration for Webpack Module Federation."""
name: str | None = Field(
default=None,
description="Module Federation container name "
"(must be valid JavaScript identifier)",
)
exposes: list[str] = Field(
default_factory=list,
description="Modules exposed by this extension",
@@ -75,6 +87,8 @@ class ContributionConfig(BaseModel):
}
}
"""
class FrontendContributions(BaseModel):
"""Frontend UI contributions."""
commands: list[dict[str, Any]] = Field(
default_factory=list,
@@ -88,25 +102,99 @@ class ContributionConfig(BaseModel):
default_factory=dict,
description="Menu contributions by scope and location",
)
editors: list[dict[str, Any]] = Field(
default_factory=list,
description="Editor contributions",
)
editors: list[dict[str, Any]] = Field(
default_factory=list,
description="Editor contributions",
)
class McpToolContribution(BaseModel):
"""MCP tool contribution."""
id: str
name: str
description: str | None = None
module: str
tags: list[str] = Field(default_factory=list)
protect: bool = True
class McpPromptContribution(BaseModel):
"""MCP prompt contribution."""
id: str
name: str
title: str | None = None
description: str | None = None
module: str
tags: list[str] = Field(default_factory=list)
protect: bool = True
class RestApiContribution(BaseModel):
"""REST API contribution."""
id: str
name: str
description: str | None = None
module: str
basePath: str # noqa: N815
# Auto-inferred Flask-AppBuilder fields
resourceName: str = "" # noqa: N815
openapiSpecTag: str = "" # noqa: N815
classPermissionName: str = "" # noqa: N815
class BackendContributions(BaseModel):
"""Backend contributions."""
mcp_tools: list[McpToolContribution] = Field(
default_factory=list,
description="MCP tools",
alias="mcp.tools",
)
mcp_prompts: list[McpPromptContribution] = Field(
default_factory=list,
description="MCP prompts",
alias="mcp.prompts",
)
rest_apis: list[RestApiContribution] = Field(
default_factory=list,
description="REST APIs",
alias="rest.apis",
)
class BaseExtension(BaseModel):
"""Base fields shared by ExtensionConfig and Manifest."""
id: str = Field(
publisher: str = Field(
...,
description="Unique extension identifier",
description="Publisher/organization namespace",
min_length=1,
pattern=PUBLISHER_PATTERN,
)
name: str = Field(
...,
description="Technical extension identifier",
min_length=1,
pattern=TECHNICAL_NAME_PATTERN,
)
displayName: str = Field( # noqa: N815
...,
description="Human-readable extension name",
min_length=1,
pattern=DISPLAY_NAME_PATTERN,
)
version: str = Field(
default="0.0.0",
description="Semantic version string",
pattern=r"^\d+\.\d+\.\d+$",
pattern=VERSION_PATTERN,
)
license: str | None = Field(
default=None,
@@ -134,10 +222,6 @@ class BaseExtension(BaseModel):
class ExtensionConfigFrontend(BaseModel):
"""Frontend section in extension.json."""
contributions: ContributionConfig = Field(
default_factory=ContributionConfig,
description="UI contribution points",
)
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
default_factory=ModuleFederationConfig,
description="Module Federation configuration",
@@ -153,7 +237,8 @@ class ExtensionConfigBackend(BaseModel):
)
files: list[str] = Field(
default_factory=list,
description="Glob patterns for backend Python files",
description="Glob patterns for backend Python files (defaults to"
"backend/src/**/*.py)",
)
@@ -161,7 +246,9 @@ class ExtensionConfig(BaseExtension):
"""
Schema for extension.json (source configuration).
This file is authored by developers to define extension metadata.
Authored by developers. Contributions can be:
- Auto-discovered from decorated code (default)
- Manually specified in contributions field (overrides discovery)
"""
frontend: ExtensionConfigFrontend | None = Field(
@@ -182,9 +269,9 @@ class ExtensionConfig(BaseExtension):
class ManifestFrontend(BaseModel):
"""Frontend section in manifest.json."""
contributions: ContributionConfig = Field(
default_factory=ContributionConfig,
description="UI contribution points",
contributions: FrontendContributions = Field(
default_factory=FrontendContributions,
description="Frontend contributions",
)
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
default_factory=ModuleFederationConfig,
@@ -203,15 +290,25 @@ class ManifestBackend(BaseModel):
default_factory=list,
description="Python module entry points to load",
)
contributions: BackendContributions = Field(
default_factory=BackendContributions,
description="Backend contributions",
)
class Manifest(BaseExtension):
"""
Schema for manifest.json (built output).
This file is generated by the build tool from extension.json.
Generated by the build tool. Contains all contributions
(discovered or manually specified) that will be registered at runtime.
"""
id: str = Field(
...,
description="Composite extension ID (publisher.name)",
min_length=1,
)
frontend: ManifestFrontend | None = Field(
default=None,
description="Frontend manifest",
+192 -67
View File
@@ -16,31 +16,78 @@
# under the License.
"""
MCP (Model Context Protocol) tool registration for Superset MCP server.
MCP (Model Context Protocol) tool and prompt registration for Superset.
This module provides a decorator interface to register MCP tools with the
host application.
This module provides decorator stubs that are replaced by the host application
during initialization. Each decorator defines metadata dataclasses that are
used for build-time discovery.
Usage:
from superset_core.mcp import tool
from superset_core.mcp import tool, prompt
@tool(name="my_tool", description="Custom business logic", tags=["extension"])
def my_extension_tool(param: str) -> dict:
return {"message": f"Hello {param}!"}
@tool(tags=["database"])
def query_database(sql: str) -> dict:
'''Execute a SQL query against a database.'''
return execute_query(sql)
# Or use function name and docstring:
@tool
def another_tool(value: int) -> str:
'''Tool description from docstring'''
return str(value * 2)
@prompt(tags={"analysis"})
async def analyze_data(ctx, dataset: str) -> str:
'''Generate analysis for a dataset.'''
return f"Analyze {dataset}..."
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, TypeVar
# Type variable for decorated functions
F = TypeVar("F", bound=Callable[..., Any])
# =============================================================================
# Metadata dataclasses - attached to decorated functions for discovery
# =============================================================================
@dataclass
class ToolMetadata:
"""
Metadata stored on functions decorated with @tool.
Attached to functions as __tool_metadata__ for build-time discovery.
"""
id: str
name: str
description: str | None = None
tags: list[str] = field(default_factory=list)
protect: bool = True
module: str = "" # Format: "package.module.function_name"
@dataclass
class PromptMetadata:
"""
Metadata stored on functions decorated with @prompt.
Attached to functions as __prompt_metadata__ for build-time discovery.
"""
id: str
name: str
title: str | None = None
description: str | None = None
tags: set[str] = field(default_factory=set)
protect: bool = True
module: str = "" # Format: "package.module.function_name"
# =============================================================================
# Decorator stubs - replaced by host application during initialization
# =============================================================================
def tool(
func_or_name: str | Callable[..., Any] | None = None,
*,
@@ -48,53 +95,90 @@ def tool(
description: str | None = None,
tags: list[str] | None = None,
protect: bool = True,
) -> Any: # Use Any to avoid mypy issues with dependency injection
) -> Any:
"""
Decorator to register an MCP tool with optional authentication.
This decorator combines FastMCP tool registration with optional authentication.
This is a stub that raises NotImplementedError until the host application
initializes the concrete implementation via dependency injection.
In BUILD mode, stores metadata for discovery without registration.
Can be used as:
@tool
def my_tool(): ...
Or:
@tool(tags=["database"])
def query(): ...
@tool(name="custom_name", protect=False)
def my_tool(): ...
Args:
func_or_name: When used as @tool, this will be the function.
When used as @tool("name"), this will be the name.
name: Tool name (defaults to function name, prefixed with extension ID)
description: Tool description (defaults to function docstring)
tags: List of tags for categorizing the tool (defaults to empty list)
name: Tool name (defaults to function name)
description: Tool description (defaults to first line of docstring)
tags: List of tags for categorizing the tool
protect: Whether to require Superset authentication (defaults to True)
Returns:
Decorator function that registers and wraps the tool, or the wrapped function
Decorated function with __tool_metadata__ attribute
Raises:
NotImplementedError: If called before host implementation is initialized
Example:
@tool(name="my_tool", description="Does something useful", tags=["utility"])
def my_custom_tool(param: str) -> dict:
return {"result": param}
@tool # Uses function name and docstring with auth
def simple_tool(value: int) -> str:
'''Doubles the input value'''
return str(value * 2)
@tool(protect=False) # No authentication required
def public_tool() -> str:
'''Public tool accessible without auth'''
return "Hello world"
NotImplementedError: Before host implementation is initialized (except in
BUILD mode)
"""
raise NotImplementedError(
"MCP tool decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
def decorator(func: F) -> F:
# Try to get context for BUILD mode detection
try:
from superset_core.extensions.context import get_context
ctx = get_context()
# In BUILD mode, store metadata for discovery
if ctx.is_build_mode:
tool_name = name or func.__name__
tool_description = description
if tool_description is None and func.__doc__:
tool_description = func.__doc__.strip().split("\n")[0]
tool_tags = tags or []
metadata = ToolMetadata(
id=func.__name__,
name=tool_name,
description=tool_description,
tags=tool_tags,
protect=protect,
module=f"{func.__module__}.{func.__name__}",
)
func.__tool_metadata__ = metadata # type: ignore[attr-defined]
return func
except ImportError:
# Context not available - fall through to error
pass
# Default behavior: raise error for host to replace
raise NotImplementedError(
"MCP tool decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
# Handle decorator usage patterns
if callable(func_or_name):
return decorator(func_or_name)
# Return parameterized decorator
actual_name = func_or_name if isinstance(func_or_name, str) else name
def parameterized_decorator(func: F) -> F:
nonlocal name
if actual_name is not None:
name = actual_name
return decorator(func)
return parameterized_decorator
def prompt(
@@ -105,57 +189,98 @@ def prompt(
description: str | None = None,
tags: set[str] | None = None,
protect: bool = True,
) -> Any: # Use Any to avoid mypy issues with dependency injection
) -> Any:
"""
Decorator to register an MCP prompt with optional authentication.
This decorator combines FastMCP prompt registration with optional authentication.
This is a stub that raises NotImplementedError until the host application
initializes the concrete implementation via dependency injection.
In BUILD mode, stores metadata for discovery without registration.
Can be used as:
@prompt
async def my_prompt_handler(): ...
async def my_prompt(ctx): ...
Or:
@prompt("my_prompt")
async def my_prompt_handler(): ...
@prompt(tags={"analysis"})
async def analyze(ctx): ...
Or:
@prompt("my_prompt", protected=False, title="Custom Title")
async def my_prompt_handler(): ...
@prompt("custom_name", title="Custom Title")
async def my_prompt(ctx): ...
Args:
func_or_name: When used as @prompt, this will be the function.
When used as @prompt("name"), this will be the name.
name: Prompt name (defaults to function name if not provided)
name: Prompt name (defaults to function name)
title: Prompt title (defaults to function name)
description: Prompt description (defaults to function docstring)
description: Prompt description (defaults to first line of docstring)
tags: Set of tags for categorizing the prompt
protect: Whether to require Superset authentication (defaults to True)
Returns:
Decorator function that registers and wraps the prompt, or the wrapped function
Decorated function with __prompt_metadata__ attribute
Raises:
NotImplementedError: If called before host implementation is initialized
Example:
@prompt
async def my_prompt_handler(ctx: Context) -> str:
'''Interactive prompt for doing something.'''
return "Prompt instructions here..."
@prompt("custom_prompt", protect=False, title="Custom Title")
async def public_prompt_handler(ctx: Context) -> str:
'''Public prompt accessible without auth'''
return "Public prompt accessible without auth"
NotImplementedError: Before host implementation is initialized (except in
BUILD mode)
"""
raise NotImplementedError(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
def decorator(func: F) -> F:
# Try to get context for BUILD mode detection
try:
from superset_core.extensions.context import get_context
ctx = get_context()
# In BUILD mode, store metadata for discovery
if ctx.is_build_mode:
prompt_name = name or func.__name__
prompt_title = title or func.__name__
prompt_description = description
if prompt_description is None and func.__doc__:
prompt_description = func.__doc__.strip().split("\n")[0]
prompt_tags = tags or set()
metadata = PromptMetadata(
id=func.__name__,
name=prompt_name,
title=prompt_title,
description=prompt_description,
tags=prompt_tags,
protect=protect,
module=f"{func.__module__}.{func.__name__}",
)
func.__prompt_metadata__ = metadata # type: ignore[attr-defined]
return func
except ImportError:
# Context not available - fall through to error
pass
# Default behavior: raise error for host to replace
raise NotImplementedError(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
# Handle decorator usage patterns
if callable(func_or_name):
return decorator(func_or_name)
# Return parameterized decorator
actual_name = func_or_name if isinstance(func_or_name, str) else name
def parameterized_decorator(func: F) -> F:
nonlocal name
if actual_name is not None:
name = actual_name
return decorator(func)
return parameterized_decorator
__all__ = [
"tool",
"prompt",
"ToolMetadata",
"PromptMetadata",
]
+16
View File
@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+147
View File
@@ -0,0 +1,147 @@
# 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 REST API decorators in BUILD mode."""
from superset_core.api.rest_api import extension_api, RestApi
from superset_core.extensions.context import get_context, RegistrationMode
def test_extension_api_decorator_stores_metadata():
"""Test that @extension_api decorator stores metadata."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@extension_api(id="test_api", name="Test API")
class TestAPI(RestApi):
"""Test API class."""
pass
# Should have metadata attached
assert hasattr(TestAPI, "__rest_api_metadata__")
meta = TestAPI.__rest_api_metadata__
assert meta.id == "test_api"
assert meta.name == "Test API"
assert meta.description == "Test API class."
assert meta.base_path == "/test_api"
assert "TestAPI" in meta.module
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_extension_api_decorator_with_custom_metadata():
"""Test @extension_api decorator with custom metadata."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@extension_api(
id="custom_api",
name="Custom API",
description="Custom description",
base_path="/custom/path",
)
class CustomAPI(RestApi):
"""Original docstring."""
pass
meta = CustomAPI.__rest_api_metadata__
assert meta.id == "custom_api"
assert meta.name == "Custom API"
assert meta.description == "Custom description"
assert meta.base_path == "/custom/path"
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_extension_api_decorator_auto_infers_flask_fields():
"""Test that @extension_api auto-infers Flask-AppBuilder fields."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@extension_api(id="infer_api", name="Infer API")
class InferAPI(RestApi):
"""Test auto-inference."""
pass
meta = InferAPI.__rest_api_metadata__
# Check auto-inferred fields
assert meta.resource_name == "infer_api"
assert meta.openapi_spec_tag == "Infer API"
assert meta.class_permission_name == "infer_api"
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_extension_api_decorator_custom_flask_fields():
"""Test @extension_api with custom Flask-AppBuilder fields."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@extension_api(
id="custom_flask_api",
name="Custom Flask API",
resource_name="custom_resource",
openapi_spec_tag="Custom Tag",
class_permission_name="custom_permission",
)
class CustomFlaskAPI(RestApi):
"""Custom Flask fields."""
pass
meta = CustomFlaskAPI.__rest_api_metadata__
assert meta.resource_name == "custom_resource"
assert meta.openapi_spec_tag == "Custom Tag"
assert meta.class_permission_name == "custom_permission"
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_extension_api_decorator_does_not_register_in_build_mode():
"""Test that @extension_api doesn't attempt registration in BUILD mode."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
# This should not raise an exception even though no registration mechanism
# is available
@extension_api(id="build_api", name="Build API")
class BuildAPI(RestApi):
"""Build mode API."""
pass
# Should have metadata
assert hasattr(BuildAPI, "__rest_api_metadata__")
finally:
ctx.set_mode(RegistrationMode.HOST)
+154
View File
@@ -0,0 +1,154 @@
# 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 MCP decorators in BUILD mode."""
from superset_core.extensions.context import get_context, RegistrationMode
from superset_core.mcp import prompt, tool
def test_tool_decorator_stores_metadata_in_build_mode():
"""Test that @tool decorator stores metadata when in BUILD mode."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@tool(tags=["database"])
def test_query_tool(sql: str) -> dict:
"""Execute a SQL query."""
return {"result": "test"}
# Should have metadata attached
assert hasattr(test_query_tool, "__tool_metadata__")
meta = test_query_tool.__tool_metadata__
assert meta.id == "test_query_tool"
assert meta.name == "test_query_tool"
assert meta.description == "Execute a SQL query."
assert meta.tags == ["database"]
assert meta.protect is True
assert "test_query_tool" in meta.module
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_tool_decorator_with_custom_metadata():
"""Test @tool decorator with custom name and description."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@tool(
name="Custom Tool",
description="Custom description",
tags=["test"],
protect=False,
)
def my_tool() -> str:
"""Original docstring."""
return "test"
meta = my_tool.__tool_metadata__
assert meta.id == "my_tool"
assert meta.name == "Custom Tool"
assert meta.description == "Custom description"
assert meta.tags == ["test"]
assert meta.protect is False
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_prompt_decorator_stores_metadata_in_build_mode():
"""Test that @prompt decorator stores metadata when in BUILD mode."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@prompt(tags=["analysis"])
async def test_prompt(ctx, dataset: str) -> str:
"""Generate analysis for a dataset."""
return f"Analyze {dataset}"
# Should have metadata attached
assert hasattr(test_prompt, "__prompt_metadata__")
meta = test_prompt.__prompt_metadata__
assert meta.id == "test_prompt"
assert meta.name == "test_prompt"
assert meta.description == "Generate analysis for a dataset."
assert meta.tags == ["analysis"]
assert meta.protect is True
assert "test_prompt" in meta.module
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_prompt_decorator_with_custom_metadata():
"""Test @prompt decorator with custom metadata."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
@prompt(
name="Custom Prompt",
title="Custom Title",
description="Custom description",
tags=["custom"],
protect=False,
)
async def my_prompt(ctx) -> str:
"""Original docstring."""
return "test"
meta = my_prompt.__prompt_metadata__
assert meta.id == "my_prompt"
assert meta.name == "Custom Prompt"
assert meta.title == "Custom Title"
assert meta.description == "Custom description"
assert meta.tags == ["custom"]
assert meta.protect is False
finally:
ctx.set_mode(RegistrationMode.HOST)
def test_decorators_do_not_register_in_build_mode():
"""Test that decorators don't attempt registration in BUILD mode."""
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
# This should not raise an exception even though no MCP server is available
@tool(tags=["test"])
def build_mode_tool() -> str:
return "test"
@prompt(tags=["test"])
async def build_mode_prompt(ctx) -> str:
return "test"
# Both should have metadata
assert hasattr(build_mode_tool, "__tool_metadata__")
assert hasattr(build_mode_prompt, "__prompt_metadata__")
finally:
ctx.set_mode(RegistrationMode.HOST)
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
import importlib.util
import inspect
import json # noqa: TID251
import re
import shutil
@@ -28,22 +30,169 @@ from typing import Any, Callable
import click
import semver
from jinja2 import Environment, FileSystemLoader
from superset_core.extensions.types import (
from superset_core.extensions import (
BackendContributions,
ExtensionConfig,
FrontendContributions,
Manifest,
ManifestBackend,
ManifestFrontend,
McpPromptContribution,
McpToolContribution,
RegistrationMode,
RestApiContribution,
get_context,
)
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from superset_extensions_cli.constants import MIN_NPM_VERSION
from superset_extensions_cli.utils import read_json, read_toml
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.types import ExtensionNames
from superset_extensions_cli.utils import (
generate_extension_names,
kebab_to_snake_case,
read_json,
read_toml,
suggest_technical_name,
validate_display_name,
validate_publisher,
validate_technical_name,
)
REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$")
FRONTEND_DIST_REGEX = re.compile(r"/frontend/dist")
def discover_backend_contributions(
cwd: Path, files_patterns: list[str]
) -> BackendContributions:
"""
Discover backend contributions by importing modules and inspecting decorated objects.
Sets context to BUILD mode so decorators only store metadata, no registration.
"""
contributions = BackendContributions()
# Set build mode so decorators don't try to register
ctx = get_context()
ctx.set_mode(RegistrationMode.BUILD)
try:
# Collect all Python files matching patterns
py_files: list[Path] = []
for pattern in files_patterns:
py_files.extend(cwd.glob(pattern))
# Filter to only process Python files
python_files = [f for f in py_files if f.is_file() and f.suffix == ".py"]
for py_file in python_files:
try:
# Import module dynamically
module = _import_module_from_path(py_file)
if module is None:
continue
# Inspect all members for decorated objects
for name, obj in inspect.getmembers(module):
if name.startswith("_"):
continue
# Check for @tool metadata
if hasattr(obj, "__tool_metadata__"):
meta = obj.__tool_metadata__
contributions.mcp_tools.append(
McpToolContribution(
id=meta.id,
name=meta.name,
description=meta.description,
module=meta.module,
tags=list(meta.tags),
protect=meta.protect,
)
)
# Check for @prompt metadata
if hasattr(obj, "__prompt_metadata__"):
meta = obj.__prompt_metadata__
contributions.mcp_prompts.append(
McpPromptContribution(
id=meta.id,
name=meta.name,
title=meta.title,
description=meta.description,
module=meta.module,
tags=list(meta.tags),
protect=meta.protect,
)
)
# Check for @extension_api metadata
if hasattr(obj, "__rest_api_metadata__"):
meta = obj.__rest_api_metadata__
contributions.rest_apis.append(
RestApiContribution(
id=meta.id,
name=meta.name,
description=meta.description,
module=meta.module,
basePath=meta.base_path,
resourceName=meta.resource_name,
openapiSpecTag=meta.openapi_spec_tag,
classPermissionName=meta.class_permission_name,
)
)
except Exception as e:
click.secho(f"⚠️ Failed to analyze {py_file}: {e}", fg="yellow")
finally:
# Reset to host mode
ctx.set_mode(RegistrationMode.HOST)
return contributions
def discover_frontend_contributions(cwd: Path) -> FrontendContributions:
"""
Discover frontend contributions from webpack plugin output.
The webpack plugin outputs a contributions.json file during build.
"""
contributions_file = cwd / "frontend" / "dist" / "contributions.json"
if not contributions_file.exists():
# No frontend contributions found - this is normal for extensions without frontend
return FrontendContributions()
try:
contributions_data = json.loads(contributions_file.read_text())
return FrontendContributions.model_validate(contributions_data)
except Exception as e:
click.secho(f"⚠️ Failed to parse frontend contributions: {e}", fg="yellow")
return FrontendContributions()
def _import_module_from_path(py_file: Path) -> Any:
"""Import a Python module from a file path."""
module_name = py_file.stem
spec = importlib.util.spec_from_file_location(module_name, py_file)
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
return module
except Exception:
# Clean up on failure
sys.modules.pop(module_name, None)
raise
def validate_npm() -> None:
"""Abort if `npm` is not on PATH."""
if shutil.which("npm") is None:
@@ -137,21 +286,59 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
extension = ExtensionConfig.model_validate(extension_data)
# Build frontend manifest with auto-discovery
frontend: ManifestFrontend | None = None
if extension.frontend and remote_entry:
click.secho("🔍 Auto-discovering frontend contributions...", fg="cyan")
frontend_contributions = discover_frontend_contributions(cwd)
# Count contributions for feedback
command_count = len(frontend_contributions.commands)
view_count = sum(len(views) for views in frontend_contributions.views.values())
menu_count = len(frontend_contributions.menus)
editor_count = len(frontend_contributions.editors)
total_count = command_count + view_count + menu_count + editor_count
if total_count > 0:
click.secho(
f" Found: {command_count} commands, {view_count} views, {menu_count} menus, {editor_count} editors",
fg="green",
)
else:
click.secho(" No frontend contributions found", fg="yellow")
frontend = ManifestFrontend(
contributions=extension.frontend.contributions,
contributions=frontend_contributions,
moduleFederation=extension.frontend.moduleFederation,
remoteEntry=remote_entry,
)
# Build backend manifest with auto-discovered contributions
backend: ManifestBackend | None = None
if extension.backend and extension.backend.entryPoints:
backend = ManifestBackend(entryPoints=extension.backend.entryPoints)
if extension.backend:
click.secho("🔍 Auto-discovering backend contributions...", fg="cyan")
backend_contributions = discover_backend_contributions(
cwd, extension.backend.files
)
tool_count = len(backend_contributions.mcp_tools)
prompt_count = len(backend_contributions.mcp_prompts)
api_count = len(backend_contributions.rest_apis)
click.secho(
f" Found: {tool_count} tools, {prompt_count} prompts, {api_count} APIs",
fg="green",
)
backend = ManifestBackend(
entryPoints=extension.backend.entryPoints,
contributions=backend_contributions,
)
return Manifest(
id=extension.id,
id=composite_id,
publisher=extension.publisher,
name=extension.name,
displayName=extension.displayName,
version=extension.version,
permissions=extension.permissions,
dependencies=extension.dependencies,
@@ -403,14 +590,111 @@ def dev(ctx: click.Context) -> None:
click.secho("❌ No directories to watch. Exiting.", fg="red")
def prompt_for_extension_info(
display_name_opt: str | None,
publisher_opt: str | None,
technical_name_opt: str | None,
) -> ExtensionNames:
"""
Prompt for extension info with graceful validation and re-prompting.
Args:
display_name_opt: Display name provided via CLI option (if any)
publisher_opt: Publisher provided via CLI option (if any)
technical_name_opt: Technical name provided via CLI option (if any)
Returns:
ExtensionNames: Validated extension name variants
"""
# Step 1: Get display name
if display_name_opt:
display_name = display_name_opt
try:
display_name = validate_display_name(display_name)
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
sys.exit(1)
else:
while True:
display_name = click.prompt("Extension display name", type=str)
try:
display_name = validate_display_name(display_name)
break
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
# Step 2: Get technical name (with suggestion from display name)
if technical_name_opt:
technical_name = technical_name_opt
try:
validate_technical_name(technical_name)
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
sys.exit(1)
else:
# Suggest technical name from display name
try:
suggested_technical = suggest_technical_name(display_name)
except ExtensionNameError:
suggested_technical = "extension"
while True:
technical_name = click.prompt(
f"Extension name ({suggested_technical})",
default=suggested_technical,
type=str,
)
try:
validate_technical_name(technical_name)
break
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
# Step 3: Get publisher
if publisher_opt:
publisher = publisher_opt
try:
validate_publisher(publisher)
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
sys.exit(1)
else:
while True:
publisher = click.prompt("Publisher (e.g., my-org)", type=str)
try:
validate_publisher(publisher)
break
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
# Generate all name variants
try:
return generate_extension_names(display_name, publisher, technical_name)
except ExtensionNameError as e:
click.secho(f"{e}", fg="red")
sys.exit(1)
@app.command()
@click.option(
"--id",
"id_opt",
"--publisher",
"publisher_opt",
default=None,
help="Extension ID (alphanumeric and underscores only)",
help="Publisher namespace (kebab-case, e.g. my-org)",
)
@click.option(
"--name",
"name_opt",
default=None,
help="Technical extension name (kebab-case, e.g. dashboard-widgets)",
)
@click.option(
"--display-name",
"display_name_opt",
default=None,
help="Extension display name (e.g. Dashboard Widgets)",
)
@click.option("--name", "name_opt", default=None, help="Extension display name")
@click.option(
"--version", "version_opt", default=None, help="Initial version (default: 0.1.0)"
)
@@ -424,25 +708,17 @@ def dev(ctx: click.Context) -> None:
"--backend/--no-backend", "backend_opt", default=None, help="Include backend"
)
def init(
id_opt: str | None,
publisher_opt: str | None,
name_opt: str | None,
display_name_opt: str | None,
version_opt: str | None,
license_opt: str | None,
frontend_opt: bool | None,
backend_opt: bool | None,
) -> None:
id_ = id_opt or click.prompt(
"Extension ID (unique identifier, alphanumeric only)", type=str
)
if not re.match(r"^[a-zA-Z0-9_]+$", id_):
click.secho(
"❌ ID must be alphanumeric (letters, digits, underscore).", fg="red"
)
sys.exit(1)
# Get extension names with graceful validation
names = prompt_for_extension_info(display_name_opt, publisher_opt, name_opt)
name = name_opt or click.prompt(
"Extension name (human-readable display name)", type=str
)
version = version_opt or click.prompt("Initial version", default="0.1.0")
license_ = license_opt or click.prompt("License", default="Apache-2.0")
include_frontend = (
@@ -456,7 +732,7 @@ def init(
else click.confirm("Include backend?", default=True)
)
target_dir = Path.cwd() / id_
target_dir = Path.cwd() / names["id"]
if target_dir.exists():
click.secho(f"❌ Directory {target_dir} already exists.", fg="red")
sys.exit(1)
@@ -465,8 +741,7 @@ def init(
templates_dir = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(templates_dir)) # noqa: S701
ctx = {
"id": id_,
"name": name,
**names, # Include all name variants
"include_frontend": include_frontend,
"include_backend": include_backend,
"license": license_,
@@ -502,29 +777,48 @@ def init(
(frontend_src_dir / "index.tsx").write_text(index_tsx)
click.secho("✅ Created frontend folder structure", fg="green")
# Initialize backend files
# Initialize backend files with superset_extensions.publisher.name structure
if include_backend:
backend_dir = target_dir / "backend"
backend_dir.mkdir()
backend_src_dir = backend_dir / "src"
backend_src_dir.mkdir()
backend_src_package_dir = backend_src_dir / id_
backend_src_package_dir.mkdir()
# Create superset_extensions namespace directory
namespace_dir = backend_src_dir / "superset_extensions"
namespace_dir.mkdir()
# Create publisher directory (e.g., superset_extensions/my_org)
publisher_snake = kebab_to_snake_case(names["publisher"])
publisher_dir = namespace_dir / publisher_snake
publisher_dir.mkdir()
# Create extension package directory (e.g., superset_extensions/my_org/dashboard_widgets)
name_snake = kebab_to_snake_case(names["name"])
extension_package_dir = publisher_dir / name_snake
extension_package_dir.mkdir()
# backend files
pyproject_toml = env.get_template("backend/pyproject.toml.j2").render(ctx)
(backend_dir / "pyproject.toml").write_text(pyproject_toml)
# Namespace package __init__.py (empty for namespace)
(namespace_dir / "__init__.py").write_text("")
(publisher_dir / "__init__.py").write_text("")
# Extension package files
init_py = env.get_template("backend/src/package/__init__.py.j2").render(ctx)
(backend_src_package_dir / "__init__.py").write_text(init_py)
(extension_package_dir / "__init__.py").write_text(init_py)
entrypoint_py = env.get_template("backend/src/package/entrypoint.py.j2").render(
ctx
)
(backend_src_package_dir / "entrypoint.py").write_text(entrypoint_py)
(extension_package_dir / "entrypoint.py").write_text(entrypoint_py)
click.secho("✅ Created backend folder structure", fg="green")
click.secho(
f"🎉 Extension {name} (ID: {id_}) initialized at {target_dir}", fg="cyan"
f"🎉 Extension {names['display_name']} (ID: {names['id']}) initialized at {target_dir}",
fg="cyan",
)
@@ -0,0 +1,22 @@
# 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.
class ExtensionNameError(Exception):
"""Raised when extension name validation fails."""
pass
@@ -1,4 +1,4 @@
[project]
name = "{{ id }}"
name = "{{ backend_package }}"
version = "{{ version }}"
license = "{{ license }}"
@@ -1 +1 @@
print("{{ name }} extension registered")
print("{{ display_name }} extension registered")
@@ -1,24 +1,21 @@
{
"id": "{{ id }}",
"publisher": "{{ publisher }}",
"name": "{{ name }}",
"displayName": "{{ display_name }}",
"version": "{{ version }}",
"license": "{{ license }}",
{% if include_frontend -%}
"frontend": {
"contributions": {
"commands": [],
"views": {},
"menus": {}
},
"moduleFederation": {
"name": "{{ mf_name }}",
"exposes": ["./index"]
}
},
{% endif -%}
{% if include_backend -%}
"backend": {
"entryPoints": ["{{ id }}.entrypoint"],
"files": ["backend/src/{{ id }}/**/*.py"]
"entryPoints": ["{{ backend_entry }}"],
"files": ["backend/src/{{ backend_path|replace('.', '/') }}/**/*.py"]
},
{% endif -%}
"permissions": []
@@ -1,5 +1,5 @@
{
"name": "{{ id }}",
"name": "{{ npm_name }}",
"version": "{{ version }}",
"main": "dist/main.js",
"types": "dist/publicAPI.d.ts",
@@ -19,6 +19,7 @@
"react-dom": "^17.0.2"
},
"devDependencies": {
"@apache-superset/webpack-extension-plugin": "^0.0.1",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@types/react": "^19.0.10",
@@ -1,13 +1,32 @@
import React from "react";
import { core } from "@apache-superset/core";
import { defineCommand, defineView } from "@apache-superset/core";
export const activate = (context: core.ExtensionContext) => {
context.disposables.push(
core.registerViewProvider("{{ id }}.example", () => <p>{{ name }}</p>)
);
console.log("{{ name }} extension activated");
};
// Example command
export const exampleCommand = defineCommand({
id: "example",
title: "Example Command",
icon: "ExperimentOutlined",
execute: async () => {
console.log("{{ name }} command executed!");
},
onActivate: () => {
console.log("Example command activated");
},
onDeactivate: () => {
console.log("Example command deactivated");
},
});
export const deactivate = () => {
console.log("{{ name }} extension deactivated");
};
// Example view
export const exampleView = defineView({
id: "example",
title: "{{ name }} View",
location: "explore.panels", // or dashboard.tabs, sqllab.panels, etc.
component: () => <div>Welcome to {{ name }}!</div>,
onActivate: () => {
console.log("Example view activated");
},
onDeactivate: () => {
console.log("Example view deactivated");
},
});
@@ -1,5 +1,6 @@
const path = require("path");
const { ModuleFederationPlugin } = require("webpack").container;
const SupersetExtensionPlugin = require("@apache-superset/webpack-extension-plugin");
const packageConfig = require("./package");
module.exports = (env, argv) => {
@@ -19,7 +20,7 @@ module.exports = (env, argv) => {
filename: isProd ? undefined : "[name].[contenthash].js",
chunkFilename: "[name].[contenthash].js",
path: path.resolve(__dirname, "dist"),
publicPath: `/api/v1/extensions/${packageConfig.name}/`,
publicPath: `/api/v1/extensions/{{ publisher }}/{{ name }}/`,
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
@@ -39,7 +40,7 @@ module.exports = (env, argv) => {
},
plugins: [
new ModuleFederationPlugin({
name: "{{ id }}",
name: "{{ mf_name }}",
filename: "remoteEntry.[contenthash].js",
exposes: {
"./index": "./src/index.tsx",
@@ -62,6 +63,12 @@ module.exports = (env, argv) => {
},
},
}),
// Auto-discover and validate frontend contributions
new SupersetExtensionPlugin({
outputPath: "contributions.json",
include: ["src/**/*.{ts,tsx,js,jsx}"],
exclude: ["**/*.test.*", "**/node_modules/**"],
}),
],
};
};
@@ -0,0 +1,49 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import TypedDict
class ExtensionNames(TypedDict):
"""Type definition for extension name variants following platform conventions."""
# Publisher namespace (e.g., "my-org")
publisher: str
# Technical extension name (e.g., "dashboard-widgets")
name: str
# Human-readable display name (e.g., "Dashboard Widgets")
display_name: str
# Composite extension ID - publisher.name (e.g., "my-org.dashboard-widgets")
id: str
# NPM package name - @publisher/name (e.g., "@my-org/dashboard-widgets")
npm_name: str
# Module Federation library - publisherCamel_nameCamel (e.g., "myOrg_dashboardWidgets")
mf_name: str
# Backend package name with hyphens for distribution (e.g., "my_org-dashboard_widgets")
backend_package: str
# Full backend import path (e.g., "superset_extensions.my_org.dashboard_widgets")
backend_path: str
# Backend entry point (e.g., "superset_extensions.my_org.dashboard_widgets.entrypoint")
backend_entry: str
@@ -16,15 +16,82 @@
# under the License.
import json # noqa: TID251
import re
import sys
from pathlib import Path
from typing import Any
from superset_core.extensions.constants import (
DISPLAY_NAME_PATTERN,
PUBLISHER_PATTERN,
TECHNICAL_NAME_PATTERN,
)
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.types import ExtensionNames
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
# Python reserved keywords to avoid in package names
PYTHON_KEYWORDS = {
"and",
"as",
"assert",
"break",
"class",
"continue",
"def",
"del",
"elif",
"else",
"except",
"exec",
"finally",
"for",
"from",
"global",
"if",
"import",
"in",
"is",
"lambda",
"not",
"or",
"pass",
"print",
"raise",
"return",
"try",
"while",
"with",
"yield",
"False",
"None",
"True",
}
# npm reserved names to avoid
NPM_RESERVED = {
"node_modules",
"favicon.ico",
"www",
"http",
"https",
"ftp",
"localhost",
"package.json",
"npm",
"yarn",
"bower_components",
}
# Compiled patterns for publisher/name validation
PUBLISHER_REGEX = re.compile(PUBLISHER_PATTERN)
TECHNICAL_NAME_REGEX = re.compile(TECHNICAL_NAME_PATTERN)
DISPLAY_NAME_REGEX = re.compile(DISPLAY_NAME_PATTERN)
def read_toml(path: Path) -> dict[str, Any] | None:
if not path.is_file():
@@ -40,3 +107,276 @@ def read_json(path: Path) -> dict[str, Any] | None:
return None
return json.loads(path.read_text())
def _normalize_for_identifiers(name: str) -> str:
"""
Normalize display name to clean lowercase words.
Args:
name: Raw display name (e.g., "Hello World!")
Returns:
Normalized string (e.g., "hello world")
"""
# Convert to lowercase
normalized = name.lower().strip()
# Convert underscores and existing hyphens to spaces for consistent processing
normalized = normalized.replace("_", " ").replace("-", " ")
# Remove any non-alphanumeric characters except spaces
normalized = re.sub(r"[^a-z0-9\s]", "", normalized)
# Normalize whitespace (collapse multiple spaces, strip)
normalized = " ".join(normalized.split())
return normalized
def _normalized_to_kebab(normalized: str) -> str:
"""Convert normalized string to kebab-case."""
return normalized.replace(" ", "-")
def _normalized_to_snake(normalized: str) -> str:
"""Convert normalized string to snake_case."""
return normalized.replace(" ", "_")
def _normalized_to_camel(normalized: str) -> str:
"""Convert normalized string to camelCase."""
parts = normalized.split()
if not parts:
return ""
# First part lowercase, subsequent parts capitalized
return parts[0] + "".join(word.capitalize() for word in parts[1:])
def kebab_to_camel_case(kebab_name: str) -> str:
"""Convert kebab-case to camelCase (e.g., 'hello-world' -> 'helloWorld')."""
parts = kebab_name.split("-")
if not parts:
return ""
# First part lowercase, subsequent parts capitalized
return parts[0] + "".join(word.capitalize() for word in parts[1:])
def kebab_to_snake_case(kebab_name: str) -> str:
"""Convert kebab-case to snake_case (e.g., 'hello-world' -> 'hello_world')."""
return kebab_name.replace("-", "_")
def name_to_kebab_case(name: str) -> str:
"""Convert display name directly to kebab-case (e.g., 'Hello World' -> 'hello-world')."""
normalized = _normalize_for_identifiers(name)
return _normalized_to_kebab(normalized)
def validate_python_package_name(name: str) -> None:
"""
Validate Python package name (snake_case format).
Raises:
ExtensionNameError: If name is invalid
"""
# Check if it starts with a number (invalid for Python identifiers)
if name[0].isdigit():
raise ExtensionNameError(f"Package name '{name}' cannot start with a number")
# Check if the first part (before any underscore) is a Python keyword
if (first_part := name.split("_")[0]) in PYTHON_KEYWORDS:
raise ExtensionNameError(
f"Package name cannot start with Python keyword '{first_part}'"
)
# Check if it's a valid Python identifier
if not name.replace("_", "a").isalnum():
raise ExtensionNameError(f"'{name}' is not a valid Python package name")
def validate_npm_package_name(name: str) -> None:
"""
Validate npm package name (kebab-case format).
Raises:
ExtensionNameError: If name is invalid
"""
if name.lower() in NPM_RESERVED:
raise ExtensionNameError(f"'{name}' is a reserved npm package name")
def validate_publisher(publisher: str) -> None:
"""
Validate publisher namespace format.
Args:
publisher: Publisher namespace (e.g., 'my-org')
Raises:
ExtensionNameError: If publisher is invalid
"""
if not publisher:
raise ExtensionNameError("Publisher cannot be empty")
if not PUBLISHER_REGEX.match(publisher):
raise ExtensionNameError(
"Publisher must start with a letter and contain only lowercase letters, numbers, and hyphens (e.g., 'my-org')"
)
def validate_technical_name(name: str) -> None:
"""
Validate technical extension name format.
Args:
name: Technical extension name (e.g., 'dashboard-widgets')
Raises:
ExtensionNameError: If name is invalid
"""
if not name:
raise ExtensionNameError("Extension name cannot be empty")
if not TECHNICAL_NAME_REGEX.match(name):
raise ExtensionNameError(
"Extension name must start with a letter and contain only lowercase letters, numbers, and hyphens (e.g., 'dashboard-widgets')"
)
def validate_display_name(display_name: str) -> str:
"""
Validate and normalize display name format.
Args:
display_name: Human-readable extension name
Returns:
Cleaned display name
Raises:
ExtensionNameError: If display name is invalid
"""
if not display_name or not display_name.strip():
raise ExtensionNameError("Display name cannot be empty")
# Normalize whitespace: strip and collapse multiple spaces
normalized = " ".join(display_name.strip().split())
if not DISPLAY_NAME_REGEX.match(normalized):
raise ExtensionNameError(
"Display name must start with a letter and can contain letters, numbers, spaces, hyphens, underscores, and dots (e.g., 'Dashboard Widgets')"
)
# Check for only whitespace/special chars after normalization
if not any(c.isalnum() for c in normalized):
raise ExtensionNameError(
"Display name must contain at least one letter or number"
)
return normalized
def suggest_technical_name(display_name: str) -> str:
"""
Suggest technical name from display name.
Args:
display_name: Human-readable name (e.g., "Dashboard Widgets!")
Returns:
Technical name suggestion (e.g., "dashboard-widgets")
"""
# Normalize for identifiers
normalized = _normalize_for_identifiers(display_name)
# Convert to kebab-case
technical_name = _normalized_to_kebab(normalized)
# Remove any leading/trailing hyphens that might result from edge cases
technical_name = technical_name.strip("-")
# Ensure we have something left
if not technical_name:
raise ExtensionNameError(
"Display name must contain at least one letter or number"
)
return technical_name
def get_module_federation_name(publisher: str, name: str) -> str:
"""
Generate Module Federation container name.
Args:
publisher: Publisher namespace (e.g., 'my-org')
name: Technical name (e.g., 'dashboard-widgets')
Returns:
Module Federation name (e.g., 'myOrg_dashboardWidgets')
"""
publisher_camel = kebab_to_camel_case(publisher)
name_camel = kebab_to_camel_case(name)
return f"{publisher_camel}_{name_camel}"
def generate_extension_names(
display_name: str, publisher: str, technical_name: str | None = None
) -> ExtensionNames:
"""
Generate all extension name variants from input.
Args:
display_name: Human-readable name (e.g., "Dashboard Widgets")
publisher: Publisher namespace (e.g., "my-org")
technical_name: Technical name override, or None to auto-generate
Returns:
ExtensionNames: Dictionary with all name variants
Raises:
ExtensionNameError: If any name is invalid
"""
# Validate and normalize inputs
display_name = validate_display_name(display_name)
validate_publisher(publisher)
# Use provided technical name or generate from display name
if technical_name is None:
technical_name = suggest_technical_name(display_name)
else:
validate_technical_name(technical_name)
# Generate composite ID
composite_id = f"{publisher}.{technical_name}"
# Generate NPM package name
npm_name = f"@{publisher}/{technical_name}"
# Generate Module Federation name
mf_name = get_module_federation_name(publisher, technical_name)
# Generate backend names with collision protection
publisher_snake = kebab_to_snake_case(publisher)
name_snake = kebab_to_snake_case(technical_name)
backend_package = f"{publisher_snake}-{name_snake}"
backend_path = f"superset_extensions.{publisher_snake}.{name_snake}"
backend_entry = f"{backend_path}.entrypoint"
# Validate the generated names
validate_python_package_name(publisher_snake)
validate_python_package_name(name_snake)
validate_npm_package_name(technical_name)
return ExtensionNames(
publisher=publisher,
name=technical_name,
display_name=display_name,
id=composite_id,
npm_name=npm_name,
mf_name=mf_name,
backend_package=backend_package,
backend_path=backend_path,
backend_entry=backend_entry,
)
+25 -14
View File
@@ -46,8 +46,9 @@ def isolated_filesystem(tmp_path):
def extension_params():
"""Default parameters for extension creation."""
return {
"id": "test_extension",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "0.1.0",
"license": "Apache-2.0",
"include_frontend": True,
@@ -58,25 +59,25 @@ def extension_params():
@pytest.fixture
def cli_input_both():
"""CLI input for creating extension with both frontend and backend."""
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\ny\ny\n"
return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
@pytest.fixture
def cli_input_frontend_only():
"""CLI input for creating extension with frontend only."""
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\ny\nn\n"
return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\ny\nn\n"
@pytest.fixture
def cli_input_backend_only():
"""CLI input for creating extension with backend only."""
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\nn\ny\n"
return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\nn\ny\n"
@pytest.fixture
def cli_input_neither():
"""CLI input for creating extension with neither frontend nor backend."""
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\nn\nn\n"
return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\nn\nn\n"
@pytest.fixture
@@ -86,10 +87,11 @@ def extension_setup_for_dev():
def _setup(base_path: Path) -> None:
import json
# Create extension.json
# Create extension.json with new structure
extension_json = {
"id": "test_extension",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -113,10 +115,12 @@ def extension_setup_for_bundling():
dist_dir = base_path / "dist"
dist_dir.mkdir(parents=True)
# Create manifest.json
# Create manifest.json with composite ID
manifest = {
"id": "test_extension",
"name": "Test Extension",
"id": "test-org.test-extension",
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -128,8 +132,15 @@ def extension_setup_for_bundling():
(frontend_dir / "remoteEntry.abc123.js").write_text("// remote entry")
(frontend_dir / "main.js").write_text("// main js")
# Create some backend files
backend_dir = dist_dir / "backend" / "src" / "test_extension"
# Create some backend files - updated path structure
backend_dir = (
dist_dir
/ "backend"
/ "src"
/ "superset_extensions"
/ "test_org"
/ "test_extension"
)
backend_dir.mkdir(parents=True)
(backend_dir / "__init__.py").write_text("# init")
@@ -0,0 +1,236 @@
# 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 backend contributions discovery.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from superset_extensions_cli.cli import discover_backend_contributions
@pytest.mark.unit
def test_discover_backend_contributions_finds_tools():
"""Test that discover_backend_contributions finds @tool decorated functions."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Create a Python file with decorated functions
backend_file = tmpdir_path / "backend.py"
backend_code = '''
from superset_core.mcp import tool, prompt
from superset_core.api.rest_api import RestApi, extension_api
@tool(tags=["database"], description="Query the database")
def query_database(sql: str) -> dict:
"""Execute a SQL query against the database."""
return {"result": "success"}
@tool(name="custom_tool", protect=False)
def my_custom_tool():
"""A custom tool with specific configuration."""
return {"custom": True}
@prompt(tags={"analysis"}, title="Data Analysis")
async def analyze_data(ctx, dataset: str) -> str:
"""Generate analysis for a dataset."""
return f"Analysis for {dataset}"
@extension_api(id="test_api", name="Test API")
class TestAPI(RestApi):
"""Test API for the extension."""
def get_data(self):
return self.response(200, result={})
'''
backend_file.write_text(backend_code)
# Run discovery
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
# Verify tools were discovered
assert len(contributions.mcp_tools) == 2
# Check first tool
tool1 = contributions.mcp_tools[0]
assert tool1.id == "query_database"
assert tool1.name == "query_database"
assert tool1.description == "Execute a SQL query against the database."
assert tool1.tags == ["database"]
assert tool1.protect is True
assert tool1.module == "backend.query_database"
# Check second tool
tool2 = contributions.mcp_tools[1]
assert tool2.id == "my_custom_tool"
assert tool2.name == "custom_tool"
assert tool2.description == "A custom tool with specific configuration."
assert tool2.tags == []
assert tool2.protect is False
assert tool2.module == "backend.my_custom_tool"
# Verify prompt was discovered
assert len(contributions.mcp_prompts) == 1
prompt1 = contributions.mcp_prompts[0]
assert prompt1.id == "analyze_data"
assert prompt1.name == "analyze_data"
assert prompt1.title == "Data Analysis"
assert prompt1.description == "Generate analysis for a dataset."
assert prompt1.tags == {"analysis"}
assert prompt1.protect is True
assert prompt1.module == "backend.analyze_data"
# Verify REST API was discovered
assert len(contributions.rest_apis) == 1
api1 = contributions.rest_apis[0]
assert api1.id == "test_api"
assert api1.name == "Test API"
assert api1.description == "Test API for the extension."
assert api1.basePath == "/test_api"
assert api1.module == "backend.TestAPI"
# Verify auto-inferred Flask-AppBuilder fields
assert api1.resourceName == "test_api" # defaults to id.lower()
assert api1.openapiSpecTag == "Test API" # defaults to name
assert api1.classPermissionName == "test_api" # defaults to resource_name
@pytest.mark.unit
def test_discover_backend_contributions_handles_empty_directory():
"""Test that discovery handles directories with no Python files."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Create a non-Python file
(tmpdir_path / "readme.txt").write_text("This is not Python")
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
assert len(contributions.mcp_tools) == 0
assert len(contributions.mcp_prompts) == 0
assert len(contributions.rest_apis) == 0
@pytest.mark.unit
def test_discover_backend_contributions_handles_syntax_errors():
"""Test that discovery handles Python files with syntax errors gracefully."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Create a Python file with syntax error
bad_file = tmpdir_path / "bad.py"
bad_file.write_text("def broken_function(\n # missing closing parenthesis")
# Create a good file with contributions
good_file = tmpdir_path / "good.py"
good_code = '''
from superset_core.mcp import tool
@tool
def working_tool():
"""This tool should be discovered."""
return {}
'''
good_file.write_text(good_code)
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
# Should discover the working tool despite the syntax error in other file
assert len(contributions.mcp_tools) == 1
assert contributions.mcp_tools[0].id == "working_tool"
@pytest.mark.unit
def test_discover_backend_contributions_skips_private_functions():
"""Test that discovery skips functions starting with underscore."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
backend_file = tmpdir_path / "backend.py"
backend_code = '''
from superset_core.mcp import tool
@tool
def public_tool():
"""This should be discovered."""
return {}
@tool
def _private_tool():
"""This should be skipped."""
return {}
'''
backend_file.write_text(backend_code)
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
# Should only find the public tool
assert len(contributions.mcp_tools) == 1
assert contributions.mcp_tools[0].id == "public_tool"
@pytest.mark.unit
def test_discover_backend_contributions_uses_file_patterns():
"""Test that discovery respects file patterns."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Create files in different subdirectories
src_dir = tmpdir_path / "src"
src_dir.mkdir()
tests_dir = tmpdir_path / "tests"
tests_dir.mkdir()
src_file = src_dir / "tools.py"
src_code = '''
from superset_core.mcp import tool
@tool
def src_tool():
"""Tool from src directory."""
return {}
'''
src_file.write_text(src_code)
test_file = tests_dir / "test_tools.py"
test_code = '''
from superset_core.mcp import tool
@tool
def test_tool():
"""Tool from tests directory."""
return {}
'''
test_file.write_text(test_code)
# Only search in src directory
contributions = discover_backend_contributions(tmpdir_path, ["src/**/*.py"])
# Should only find the src tool
assert len(contributions.mcp_tools) == 1
assert contributions.mcp_tools[0].id == "src_tool"
+47 -28
View File
@@ -52,20 +52,25 @@ def extension_with_build_structure():
# Create extension.json
extension_json = {
"id": "test_extension",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
if include_frontend:
extension_json["frontend"] = {
"contributions": {"commands": []},
"moduleFederation": {"exposes": ["./index"]},
}
if include_backend:
extension_json["backend"] = {"entryPoints": ["test_extension.entrypoint"]}
extension_json["backend"] = {
"entryPoints": [
"superset_extensions.test_org.test_extension.entrypoint"
],
"files": ["backend/src/**/*.py"],
}
(base_path / "extension.json").write_text(json.dumps(extension_json))
@@ -230,16 +235,19 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
"""Test build_manifest creates correct manifest from extension.json."""
# Create extension.json
extension_data = {
"id": "test_extension",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": ["read_data"],
"dependencies": ["some_dep"],
"frontend": {
"contributions": {"commands": [{"id": "test_command", "title": "Test"}]},
"moduleFederation": {"exposes": ["./index"]},
},
"backend": {"entryPoints": ["test_extension.entrypoint"]},
"backend": {
"entryPoints": ["superset_extensions.test_org.test_extension.entrypoint"],
"files": ["backend/src/**/*.py"],
},
}
extension_json = isolated_filesystem / "extension.json"
extension_json.write_text(json.dumps(extension_data))
@@ -247,31 +255,36 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
manifest = build_manifest(isolated_filesystem, "remoteEntry.abc123.js")
# Verify manifest structure
assert manifest.id == "test_extension"
assert manifest.name == "Test Extension"
assert manifest.id == "test-org.test-extension" # Composite ID
assert manifest.publisher == "test-org"
assert manifest.name == "test-extension"
assert manifest.displayName == "Test Extension"
assert manifest.version == "1.0.0"
assert manifest.permissions == ["read_data"]
assert manifest.dependencies == ["some_dep"]
# Verify frontend section
# Verify frontend section (auto-discovery, currently empty)
assert manifest.frontend is not None
assert manifest.frontend.contributions.commands == [
{"id": "test_command", "title": "Test"}
]
assert (
manifest.frontend.contributions.commands == []
) # Auto-discovered (empty for now)
assert manifest.frontend.moduleFederation.exposes == ["./index"]
assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
# Verify backend section
# Verify backend section (auto-discovery)
assert manifest.backend is not None
assert manifest.backend.entryPoints == ["test_extension.entrypoint"]
assert manifest.backend.entryPoints == [
"superset_extensions.test_org.test_extension.entrypoint"
]
@pytest.mark.unit
def test_build_manifest_handles_minimal_extension(isolated_filesystem):
"""Test build_manifest with minimal extension.json (no frontend/backend)."""
extension_data = {
"id": "minimal_extension",
"name": "Minimal Extension",
"publisher": "minimal-org",
"name": "minimal-extension",
"displayName": "Minimal Extension",
"version": "0.1.0",
"permissions": [],
}
@@ -280,8 +293,10 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem):
manifest = build_manifest(isolated_filesystem, None)
assert manifest.id == "minimal_extension"
assert manifest.name == "Minimal Extension"
assert manifest.id == "minimal-org.minimal-extension" # Composite ID
assert manifest.publisher == "minimal-org"
assert manifest.name == "minimal-extension"
assert manifest.displayName == "Minimal Extension"
assert manifest.version == "0.1.0"
assert manifest.permissions == []
assert manifest.dependencies == [] # Default empty list
@@ -393,8 +408,9 @@ def test_rebuild_backend_calls_copy_and_shows_message(isolated_filesystem):
# Create extension.json
extension_json = {
"id": "test",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -420,8 +436,9 @@ def test_copy_backend_files_skips_non_files(isolated_filesystem):
# Create extension.json with backend file patterns
extension_data = {
"id": "test_ext",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-ext",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
"backend": {
@@ -457,8 +474,9 @@ def test_copy_backend_files_copies_matched_files(isolated_filesystem):
# Create extension.json with backend file patterns
extension_data = {
"id": "test_ext",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-ext",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
"backend": {"files": ["backend/src/test_ext/**/*.py"]},
@@ -480,8 +498,9 @@ def test_copy_backend_files_copies_matched_files(isolated_filesystem):
def test_copy_backend_files_handles_no_backend_config(isolated_filesystem):
"""Test copy_backend_files handles extension.json without backend config."""
extension_data = {
"id": "frontend_only",
"name": "Frontend Only Extension",
"publisher": "frontend-org",
"name": "frontend-only",
"displayName": "Frontend Only Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -43,10 +43,10 @@ def test_bundle_command_creates_zip_with_default_name(
result = cli_runner.invoke(app, ["bundle"])
assert result.exit_code == 0
assert "✅ Bundle created: test_extension-1.0.0.supx" in result.output
assert "✅ Bundle created: test-org.test-extension-1.0.0.supx" in result.output
# Verify zip file was created
zip_path = isolated_filesystem / "test_extension-1.0.0.supx"
zip_path = isolated_filesystem / "test-org.test-extension-1.0.0.supx"
assert_file_exists(zip_path)
# Verify zip contents
@@ -55,7 +55,10 @@ def test_bundle_command_creates_zip_with_default_name(
assert "manifest.json" in file_list
assert "frontend/dist/remoteEntry.abc123.js" in file_list
assert "frontend/dist/main.js" in file_list
assert "backend/src/test_extension/__init__.py" in file_list
assert (
"backend/src/superset_extensions/test_org/test_extension/__init__.py"
in file_list
)
@pytest.mark.cli
@@ -100,7 +103,7 @@ def test_bundle_command_with_output_directory(
assert result.exit_code == 0
# Verify zip file was created in output directory
expected_path = output_dir / "test_extension-1.0.0.supx"
expected_path = output_dir / "test-org.test-extension-1.0.0.supx"
assert_file_exists(expected_path)
assert f"✅ Bundle created: {expected_path}" in result.output
@@ -159,8 +162,10 @@ def test_bundle_includes_all_files_recursively(
# Manifest
manifest = {
"id": "complex_extension",
"name": "Complex Extension",
"id": "complex-org.complex-extension",
"publisher": "complex-org",
"name": "complex-extension",
"displayName": "Complex Extension",
"version": "2.1.0",
"permissions": [],
}
@@ -191,7 +196,7 @@ def test_bundle_includes_all_files_recursively(
assert result.exit_code == 0
# Verify zip file and contents
zip_path = isolated_filesystem / "complex_extension-2.1.0.supx"
zip_path = isolated_filesystem / "complex-org.complex-extension-2.1.0.supx"
assert_file_exists(zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
+24 -5
View File
@@ -49,7 +49,13 @@ def test_dev_command_starts_watchers(
"""Test dev command starts file watchers."""
# Setup mocks
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0")
mock_build_manifest.return_value = Manifest(
id="test-org.test-extension",
publisher="test-org",
name="test-extension",
displayName="Test Extension",
version="1.0.0",
)
mock_observer = Mock()
mock_observer_class.return_value = mock_observer
@@ -101,7 +107,13 @@ def test_dev_command_initial_build(
"""Test dev command performs initial build setup."""
# Setup mocks
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0")
mock_build_manifest.return_value = Manifest(
id="test-org.test-extension",
publisher="test-org",
name="test-extension",
displayName="Test Extension",
version="1.0.0",
)
extension_setup_for_dev(isolated_filesystem)
@@ -178,8 +190,9 @@ def test_frontend_watcher_function_coverage(isolated_filesystem):
"""Test frontend watcher function for coverage."""
# Create extension.json
extension_json = {
"id": "test_extension",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": [],
}
@@ -189,7 +202,13 @@ def test_frontend_watcher_function_coverage(isolated_filesystem):
dist_dir = isolated_filesystem / "dist"
dist_dir.mkdir()
mock_manifest = Manifest(id="test", name="test", version="1.0.0")
mock_manifest = Manifest(
id="test-org.test-extension",
publisher="test-org",
name="test-extension",
displayName="Test Extension",
version="1.0.0",
)
with patch("superset_extensions_cli.cli.rebuild_frontend") as mock_rebuild:
with patch("superset_extensions_cli.cli.build_manifest") as mock_build:
with patch("superset_extensions_cli.cli.write_manifest") as mock_write:
+123 -90
View File
@@ -43,16 +43,17 @@ def test_init_creates_extension_with_both_frontend_and_backend(
assert result.exit_code == 0, f"Command failed with output: {result.output}"
assert (
"🎉 Extension Test Extension (ID: test_extension) initialized" in result.output
"🎉 Extension Test Extension (ID: test-org.test-extension) initialized"
in result.output
)
# Verify directory structure
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-org.test-extension"
assert_directory_exists(extension_path, "main extension directory")
expected_structure = create_test_extension_structure(
isolated_filesystem,
"test_extension",
"test-org.test-extension",
include_frontend=True,
include_backend=True,
)
@@ -73,7 +74,7 @@ def test_init_creates_extension_with_frontend_only(
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-org.test-extension"
assert_directory_exists(extension_path)
# Should have frontend directory and package.json
@@ -96,7 +97,7 @@ def test_init_creates_extension_with_backend_only(
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-org.test-extension"
assert_directory_exists(extension_path)
# Should have backend directory and pyproject.toml
@@ -119,7 +120,7 @@ def test_init_creates_extension_with_neither_frontend_nor_backend(
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-org.test-extension"
assert_directory_exists(extension_path)
# Should only have extension.json
@@ -130,54 +131,54 @@ def test_init_creates_extension_with_neither_frontend_nor_backend(
assert not (extension_path / "backend").exists()
@pytest.mark.cli
def test_init_accepts_valid_display_name(cli_runner, isolated_filesystem):
"""Test that init accepts valid display names and generates proper ID."""
cli_input = "My Awesome Extension\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, f"Should accept display name: {result.output}"
assert Path("test-org.my-awesome-extension").exists(), (
"Directory for generated composite ID should be created"
)
@pytest.mark.cli
def test_init_accepts_mixed_alphanumeric_name(cli_runner, isolated_filesystem):
"""Test that init accepts mixed alphanumeric display names."""
cli_input = "Tool 123\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, (
f"Mixed alphanumeric display name should be valid: {result.output}"
)
assert Path("test-org.tool-123").exists(), (
"Directory for 'test-org.tool-123' should be created"
)
@pytest.mark.cli
@pytest.mark.parametrize(
"invalid_name,expected_error",
"display_name,expected_id",
[
("test-extension", "must be alphanumeric"),
("test extension", "must be alphanumeric"),
("test.extension", "must be alphanumeric"),
("test@extension", "must be alphanumeric"),
("", "must be alphanumeric"),
("Test Extension", "test-org.test-extension"),
("My Tool v2", "test-org.my-tool-v2"),
("Dashboard Helper", "test-org.dashboard-helper"),
("Chart Builder Pro", "test-org.chart-builder-pro"),
],
)
def test_init_validates_extension_name(
cli_runner, isolated_filesystem, invalid_name, expected_error
):
"""Test that init validates extension names according to regex pattern."""
cli_input = f"{invalid_name}\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 1, (
f"Expected command to fail for invalid name '{invalid_name}'"
)
assert expected_error in result.output
@pytest.mark.cli
def test_init_accepts_numeric_extension_name(cli_runner, isolated_filesystem):
"""Test that init accepts numeric extension ids like '123'."""
cli_input = "123\n123\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, f"Numeric id '123' should be valid: {result.output}"
assert Path("123").exists(), "Directory for '123' should be created"
@pytest.mark.cli
@pytest.mark.parametrize(
"valid_id", ["test123", "TestExtension", "test_extension_123", "MyExt_1"]
)
def test_init_with_valid_alphanumeric_names(cli_runner, valid_id):
"""Test that init accepts various valid alphanumeric names."""
def test_init_with_various_display_names(cli_runner, display_name, expected_id):
"""Test that init accepts various display names and generates proper IDs."""
with cli_runner.isolated_filesystem():
cli_input = f"{valid_id}\nTest Extension\n0.1.0\nApache-2.0\ny\ny\n"
cli_input = f"{display_name}\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0, (
f"Valid name '{valid_id}' was rejected: {result.output}"
f"Valid display name '{display_name}' was rejected: {result.output}"
)
assert Path(expected_id).exists(), (
f"Directory for '{expected_id}' was not created"
)
assert Path(valid_id).exists(), f"Directory for '{valid_id}' was not created"
@pytest.mark.cli
@@ -186,7 +187,7 @@ def test_init_fails_when_directory_already_exists(
):
"""Test that init fails gracefully when target directory already exists."""
# Create the directory first
existing_dir = isolated_filesystem / "test_extension"
existing_dir = isolated_filesystem / "test-org.test-extension"
existing_dir.mkdir()
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
@@ -203,15 +204,16 @@ def test_extension_json_content_is_correct(
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
assert result.exit_code == 0
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-org.test-extension"
extension_json_path = extension_path / "extension.json"
# Verify the JSON structure and values
assert_json_content(
extension_json_path,
{
"id": "test_extension",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-extension",
"displayName": "Test Extension",
"version": "0.1.0",
"license": "Apache-2.0",
"permissions": [],
@@ -226,16 +228,28 @@ def test_extension_json_content_is_correct(
frontend = content["frontend"]
assert "contributions" in frontend
assert "moduleFederation" in frontend
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
assert frontend["contributions"] == {
"commands": [],
"views": {},
"menus": {},
"editors": [],
}
assert frontend["moduleFederation"] == {
"exposes": ["./index"],
"name": "testOrg_testExtension",
}
# Verify backend section exists and has correct structure
assert "backend" in content
backend = content["backend"]
assert "entryPoints" in backend
assert "files" in backend
assert backend["entryPoints"] == ["test_extension.entrypoint"]
assert backend["files"] == ["backend/src/test_extension/**/*.py"]
assert backend["entryPoints"] == [
"superset_extensions.test_org.test_extension.entrypoint"
]
assert backend["files"] == [
"backend/src/superset_extensions/test_org/test_extension/**/*.py"
]
@pytest.mark.cli
@@ -246,14 +260,14 @@ def test_frontend_package_json_content_is_correct(
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
assert result.exit_code == 0
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-org.test-extension"
package_json_path = extension_path / "frontend" / "package.json"
# Verify the package.json structure and values
assert_json_content(
package_json_path,
{
"name": "test_extension",
"name": "@test-org/test-extension",
"version": "0.1.0",
"license": "Apache-2.0",
},
@@ -275,14 +289,16 @@ def test_backend_pyproject_toml_is_created(
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
assert result.exit_code == 0
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-org.test-extension"
pyproject_path = extension_path / "backend" / "pyproject.toml"
assert_file_exists(pyproject_path, "backend pyproject.toml")
# Basic content verification (without parsing TOML for now)
content = pyproject_path.read_text()
assert "test_extension" in content
assert (
"test_org-test_extension" in content
) # Package name uses collision-safe naming
assert "0.1.0" in content
assert "Apache-2.0" in content
@@ -300,7 +316,9 @@ def test_init_command_output_messages(cli_runner, isolated_filesystem, cli_input
assert "Created .gitignore" in output
assert "Created frontend folder structure" in output
assert "Created backend folder structure" in output
assert "Extension Test Extension (ID: test_extension) initialized" in output
assert (
"Extension Test Extension (ID: test-org.test-extension) initialized" in output
)
@pytest.mark.cli
@@ -309,7 +327,7 @@ def test_gitignore_content_is_correct(cli_runner, isolated_filesystem, cli_input
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
assert result.exit_code == 0
extension_path = isolated_filesystem / "test_extension"
extension_path = isolated_filesystem / "test-org.test-extension"
gitignore_path = extension_path / ".gitignore"
assert_file_exists(gitignore_path, ".gitignore")
@@ -329,19 +347,20 @@ def test_gitignore_content_is_correct(cli_runner, isolated_filesystem, cli_input
@pytest.mark.cli
def test_init_with_custom_version_and_license(cli_runner, isolated_filesystem):
"""Test init with custom version and license parameters."""
cli_input = "my_extension\nMy Extension\n2.1.0\nMIT\ny\nn\n"
cli_input = "My Extension\n\ntest-org\n2.1.0\nMIT\ny\nn\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
assert result.exit_code == 0
extension_path = isolated_filesystem / "my_extension"
extension_path = isolated_filesystem / "test-org.my-extension"
extension_json_path = extension_path / "extension.json"
assert_json_content(
extension_json_path,
{
"id": "my_extension",
"name": "My Extension",
"publisher": "test-org",
"name": "my-extension",
"displayName": "My Extension",
"version": "2.1.0",
"license": "MIT",
},
@@ -353,17 +372,17 @@ def test_init_with_custom_version_and_license(cli_runner, isolated_filesystem):
def test_full_init_workflow_integration(cli_runner, isolated_filesystem):
"""Integration test for the complete init workflow."""
# Test the complete flow with realistic user input
cli_input = "awesome_charts\nAwesome Charts\n1.0.0\nApache-2.0\ny\ny\n"
cli_input = "Awesome Charts\n\nawesome-org\n1.0.0\nApache-2.0\ny\ny\n"
result = cli_runner.invoke(app, ["init"], input=cli_input)
# Verify success
assert result.exit_code == 0
# Verify complete directory structure
extension_path = isolated_filesystem / "awesome_charts"
extension_path = isolated_filesystem / "awesome-org.awesome-charts"
expected_structure = create_test_extension_structure(
isolated_filesystem,
"awesome_charts",
"awesome-org.awesome-charts",
include_frontend=True,
include_backend=True,
)
@@ -374,16 +393,19 @@ def test_full_init_workflow_integration(cli_runner, isolated_filesystem):
# Verify all generated files have correct content
extension_json = load_json_file(extension_path / "extension.json")
assert extension_json["id"] == "awesome_charts"
assert extension_json["name"] == "Awesome Charts"
assert extension_json["publisher"] == "awesome-org"
assert extension_json["name"] == "awesome-charts"
assert extension_json["displayName"] == "Awesome Charts"
assert extension_json["version"] == "1.0.0"
assert extension_json["license"] == "Apache-2.0"
package_json = load_json_file(extension_path / "frontend" / "package.json")
assert package_json["name"] == "awesome_charts"
assert package_json["name"] == "@awesome-org/awesome-charts"
pyproject_content = (extension_path / "backend" / "pyproject.toml").read_text()
assert "awesome_charts" in pyproject_content
assert (
"awesome_org-awesome_charts" in pyproject_content
) # Package name uses collision-safe naming
# Non-interactive mode tests
@@ -394,9 +416,11 @@ def test_init_non_interactive_with_all_options(cli_runner, isolated_filesystem):
app,
[
"init",
"--id",
"my_ext",
"--publisher",
"my-org",
"--name",
"my-ext",
"--display-name",
"My Extension",
"--version",
"1.0.0",
@@ -408,16 +432,17 @@ def test_init_non_interactive_with_all_options(cli_runner, isolated_filesystem):
)
assert result.exit_code == 0, f"Command failed with output: {result.output}"
assert "🎉 Extension My Extension (ID: my_ext) initialized" in result.output
assert "🎉 Extension My Extension (ID: my-org.my-ext) initialized" in result.output
extension_path = isolated_filesystem / "my_ext"
extension_path = isolated_filesystem / "my-org.my-ext"
assert_directory_exists(extension_path)
assert_directory_exists(extension_path / "frontend")
assert_directory_exists(extension_path / "backend")
extension_json = load_json_file(extension_path / "extension.json")
assert extension_json["id"] == "my_ext"
assert extension_json["name"] == "My Extension"
assert extension_json["publisher"] == "my-org"
assert extension_json["name"] == "my-ext"
assert extension_json["displayName"] == "My Extension"
assert extension_json["version"] == "1.0.0"
assert extension_json["license"] == "MIT"
@@ -429,9 +454,11 @@ def test_init_frontend_only_with_cli_options(cli_runner, isolated_filesystem):
app,
[
"init",
"--id",
"frontend_ext",
"--publisher",
"frontend-org",
"--name",
"frontend-ext",
"--display-name",
"Frontend Extension",
"--version",
"1.0.0",
@@ -444,7 +471,7 @@ def test_init_frontend_only_with_cli_options(cli_runner, isolated_filesystem):
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "frontend_ext"
extension_path = isolated_filesystem / "frontend-org.frontend-ext"
assert_directory_exists(extension_path / "frontend")
assert not (extension_path / "backend").exists()
@@ -456,9 +483,11 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
app,
[
"init",
"--id",
"backend_ext",
"--publisher",
"backend-org",
"--name",
"backend-ext",
"--display-name",
"Backend Extension",
"--version",
"1.0.0",
@@ -471,7 +500,7 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "backend_ext"
extension_path = isolated_filesystem / "backend-org.backend-ext"
assert not (extension_path / "frontend").exists()
assert_directory_exists(extension_path / "backend")
@@ -479,14 +508,16 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
@pytest.mark.cli
def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
"""Test that init prompts for options not provided via CLI and uses defaults."""
# Provide id and name via CLI, but version/license will be prompted (accept defaults)
# Provide publisher, name, and display-name via CLI, but version/license will be prompted (accept defaults)
result = cli_runner.invoke(
app,
[
"init",
"--id",
"default_ext",
"--publisher",
"default-org",
"--name",
"default-ext",
"--display-name",
"Default Extension",
"--frontend",
"--backend",
@@ -496,22 +527,24 @@ def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
assert result.exit_code == 0, f"Command failed with output: {result.output}"
extension_path = isolated_filesystem / "default_ext"
extension_path = isolated_filesystem / "default-org.default-ext"
extension_json = load_json_file(extension_path / "extension.json")
assert extension_json["version"] == "0.1.0"
assert extension_json["license"] == "Apache-2.0"
@pytest.mark.cli
def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
"""Test that non-interactive mode validates extension ID."""
def test_init_non_interactive_validates_technical_name(cli_runner, isolated_filesystem):
"""Test that non-interactive mode validates technical name."""
result = cli_runner.invoke(
app,
[
"init",
"--id",
"invalid-id",
"--publisher",
"test-org",
"--name",
"invalid_name",
"--display-name",
"Invalid Extension",
"--frontend",
"--backend",
@@ -519,4 +552,4 @@ def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
)
assert result.exit_code == 1
assert "must be alphanumeric" in result.output
assert "must start with a letter" in result.output.lower()
@@ -0,0 +1,502 @@
# 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 pytest
from superset_extensions_cli.exceptions import ExtensionNameError
from superset_extensions_cli.utils import (
generate_extension_names,
get_module_federation_name,
kebab_to_camel_case,
kebab_to_snake_case,
name_to_kebab_case,
suggest_technical_name,
validate_display_name,
validate_npm_package_name,
validate_publisher,
validate_python_package_name,
validate_technical_name,
)
# Name transformation tests
@pytest.mark.parametrize(
("display_name", "expected"),
[
("Hello World", "hello-world"),
("Data Explorer", "data-explorer"),
("My Extension", "my-extension"),
("hello-world", "hello-world"), # Already normalized
("Hello@World!", "helloworld"), # Special chars removed
(
"Data_Explorer",
"data-explorer",
), # Underscores become spaces then hyphens
("My Extension", "my-extension"), # Multiple spaces normalized
(" Hello World ", "hello-world"), # Trimmed
("API v2 Client", "api-v2-client"), # Numbers preserved
("Simple", "simple"), # Single word
],
)
def test_name_to_kebab_case(display_name, expected):
"""Test direct kebab case conversion from display names."""
assert name_to_kebab_case(display_name) == expected
@pytest.mark.parametrize(
("kebab_name", "expected"),
[
("hello-world", "helloWorld"),
("data-explorer", "dataExplorer"),
("my-extension", "myExtension"),
("api-v2-client", "apiV2Client"),
("simple", "simple"), # Single word
("chart-tool", "chartTool"),
("dashboard-helper", "dashboardHelper"),
],
)
def test_kebab_to_camel_case(kebab_name, expected):
"""Test kebab-case to camelCase conversion."""
assert kebab_to_camel_case(kebab_name) == expected
@pytest.mark.parametrize(
("kebab_name", "expected"),
[
("hello-world", "hello_world"),
("data-explorer", "data_explorer"),
("my-extension", "my_extension"),
("api-v2-client", "api_v2_client"),
("simple", "simple"), # Single word
("chart-tool", "chart_tool"),
("dashboard-helper", "dashboard_helper"),
],
)
def test_kebab_to_snake_case(kebab_name, expected):
"""Test kebab-case to snake_case conversion."""
assert kebab_to_snake_case(kebab_name) == expected
# Display name validation tests
@pytest.mark.parametrize(
("valid_display", "expected_normalized"),
[
("Hello World", "Hello World"),
("Data Explorer", "Data Explorer"),
("My Extension", "My Extension"),
("Simple", "Simple"),
(" Extra Spaces ", "Extra Spaces"), # Gets normalized
("Dashboard Widgets", "Dashboard Widgets"),
("Chart Builder Pro", "Chart Builder Pro"),
("API Client v2.0", "API Client v2.0"),
("Tool_123", "Tool_123"), # Underscores allowed
("My-Extension", "My-Extension"), # Hyphens allowed
],
)
def test_validate_display_name_valid(valid_display, expected_normalized):
"""Test valid display names return correctly normalized output."""
result = validate_display_name(valid_display)
assert result == expected_normalized
@pytest.mark.parametrize(
("invalid_display", "error_match"),
[
("", "cannot be empty"),
(" ", "cannot be empty"),
("@#$%", "must start with a letter"),
("123 Tool", "must start with a letter"),
("-My Extension", "must start with a letter"),
],
)
def test_validate_display_name_invalid(invalid_display, error_match):
"""Test invalid display names."""
with pytest.raises(ExtensionNameError, match=error_match):
validate_display_name(invalid_display)
# Python package name validation tests
@pytest.mark.parametrize(
("valid_package",),
[
("hello_world",),
("data_explorer",),
("myext",),
("test123",),
("package_with_many_parts",),
],
)
def test_validate_python_package_name_valid(valid_package):
"""Test valid Python package names."""
# Should not raise exceptions
validate_python_package_name(valid_package)
@pytest.mark.parametrize(
("keyword",),
[
("class",),
("import",),
("def",),
("return",),
("if",),
("else",),
("for",),
("while",),
("try",),
("except",),
("finally",),
("with",),
("as",),
("lambda",),
("yield",),
("False",),
("None",),
("True",),
],
)
def test_validate_python_package_name_keywords(keyword):
"""Test that Python reserved keywords are rejected."""
with pytest.raises(
ExtensionNameError, match="Package name cannot start with Python keyword"
):
validate_python_package_name(keyword)
@pytest.mark.parametrize(
("invalid_package",),
[
("hello-world",), # Hyphens not allowed in Python identifiers
],
)
def test_validate_python_package_name_invalid(invalid_package):
"""Test invalid Python package names."""
with pytest.raises(ExtensionNameError, match="not a valid Python package"):
validate_python_package_name(invalid_package)
# NPM package validation tests
@pytest.mark.parametrize(
("valid_npm",),
[
("hello-world",),
("data-explorer",),
("myext",),
("package-with-many-parts",),
],
)
def test_validate_npm_package_name_valid(valid_npm):
"""Test valid npm package names."""
# Should not raise exceptions
validate_npm_package_name(valid_npm)
@pytest.mark.parametrize(
("reserved_name",),
[
("node_modules",),
("npm",),
("yarn",),
("package.json",),
("localhost",),
("favicon.ico",),
],
)
def test_validate_npm_package_name_reserved(reserved_name):
"""Test that npm reserved names are rejected."""
with pytest.raises(ExtensionNameError, match="reserved npm package name"):
validate_npm_package_name(reserved_name)
# Publisher validation tests
@pytest.mark.parametrize(
("valid_publisher",),
[
("my-org",),
("acme",),
("apache-superset",),
("test123",),
("a",), # Single character
("publisher-with-many-parts",),
],
)
def test_validate_publisher_valid(valid_publisher):
"""Test valid publisher namespaces."""
# Should not raise exceptions
validate_publisher(valid_publisher)
@pytest.mark.parametrize(
("invalid_publisher", "error_match"),
[
("", "cannot be empty"),
("My-Org", "must start with a letter and contain only lowercase letters"),
("-publisher", "must start with a letter and contain only lowercase letters"),
("publisher-", "must start with a letter and contain only lowercase letters"),
("pub--lisher", "must start with a letter and contain only lowercase letters"),
],
)
def test_validate_publisher_invalid(invalid_publisher, error_match):
"""Test invalid publisher namespaces."""
with pytest.raises(ExtensionNameError, match=error_match):
validate_publisher(invalid_publisher)
# Technical name validation tests
@pytest.mark.parametrize(
("valid_name",),
[
("dashboard-widgets",),
("chart-builder",),
("simple",),
("api-client-v2",),
("tool123",),
],
)
def test_validate_technical_name_valid(valid_name):
"""Test valid technical names."""
# Should not raise exceptions
validate_technical_name(valid_name)
@pytest.mark.parametrize(
("invalid_name", "error_match"),
[
("", "cannot be empty"),
(
"Dashboard-Widgets",
"must start with a letter and contain only lowercase letters",
),
("-name", "must start with a letter and contain only lowercase letters"),
("name-", "must start with a letter and contain only lowercase letters"),
("na--me", "must start with a letter and contain only lowercase letters"),
],
)
def test_validate_technical_name_invalid(invalid_name, error_match):
"""Test invalid technical names."""
with pytest.raises(ExtensionNameError, match=error_match):
validate_technical_name(invalid_name)
# Name suggestion tests
@pytest.mark.parametrize(
("display_name", "expected_technical"),
[
("Dashboard Widgets", "dashboard-widgets"),
("Chart Builder Pro!", "chart-builder-pro"),
("My@Tool#123", "mytool123"),
(" Spaced Out ", "spaced-out"),
("API v2 Client", "api-v2-client"),
],
)
def test_suggest_technical_name(display_name, expected_technical):
"""Test technical name suggestion from display names."""
result = suggest_technical_name(display_name)
assert result == expected_technical
@pytest.mark.parametrize(
("publisher", "name", "expected_mf"),
[
("my-org", "dashboard-widgets", "myOrg_dashboardWidgets"),
("acme", "chart-builder", "acme_chartBuilder"),
("test-company", "simple", "testCompany_simple"),
],
)
def test_get_module_federation_name(publisher, name, expected_mf):
"""Test Module Federation name generation."""
result = get_module_federation_name(publisher, name)
assert result == expected_mf
# Complete name generation tests
@pytest.mark.parametrize(
("display_name", "expected_kebab", "expected_snake", "expected_camel"),
[
("Hello World", "hello-world", "hello_world", "helloWorld"),
("Data Explorer", "data-explorer", "data_explorer", "dataExplorer"),
("My Extension v2", "my-extension-v2", "my_extension_v2", "myExtensionV2"),
("Chart Tool", "chart-tool", "chart_tool", "chartTool"),
("Simple", "simple", "simple", "simple"),
("API v2 Client", "api-v2-client", "api_v2_client", "apiV2Client"),
(
"Dashboard Helper",
"dashboard-helper",
"dashboard_helper",
"dashboardHelper",
),
],
)
def test_generate_extension_names_complete_flow(
display_name, expected_kebab, expected_snake, expected_camel
):
"""Test complete name generation flow with publisher concept."""
publisher = "test-org"
names = generate_extension_names(display_name, publisher, expected_kebab)
# Test all transformations with publisher concept
assert names["display_name"] == display_name
assert names["publisher"] == publisher
assert names["name"] == expected_kebab # Technical name
assert names["id"] == f"{publisher}.{expected_kebab}" # Composite ID
assert names["npm_name"] == f"@{publisher}/{expected_kebab}" # NPM scoped
assert (
names["mf_name"] == f"testOrg_{expected_camel}"
) # Module Federation with publisher prefix
assert (
names["backend_package"] == f"{publisher.replace('-', '_')}-{expected_snake}"
) # Collision-safe
assert (
names["backend_path"]
== f"superset_extensions.{publisher.replace('-', '_')}.{expected_snake}"
)
assert (
names["backend_entry"]
== f"superset_extensions.{publisher.replace('-', '_')}.{expected_snake}.entrypoint"
)
@pytest.mark.parametrize(
("invalid_display",),
[
("Class Helper",), # Would create 'class_helper' - reserved keyword
("Import Tool",), # Would create 'import_tool' - reserved keyword
("@#$%",), # All special chars - becomes empty
("123 Tool",), # Starts with number after kebab conversion
],
)
def test_generate_extension_names_invalid(invalid_display):
"""Test invalid name generation scenarios."""
with pytest.raises(ExtensionNameError):
generate_extension_names(invalid_display, "test-org")
def test_generate_extension_names_unicode():
"""Test handling of unicode characters."""
# Use a simpler approach - the display name validation now requires starting with letter
names = generate_extension_names("Cafe Extension", "test-org", "cafe-extension")
assert names["id"] == "test-org.cafe-extension"
assert names["display_name"] == "Cafe Extension" # Original preserved
def test_generate_extension_names_special_chars():
"""Test name generation with special characters."""
# Use manual technical name since display validation is stricter
names = generate_extension_names("My Extension", "test-org", "my-extension")
assert names["display_name"] == "My Extension"
assert names["id"] == "test-org.my-extension"
assert names["backend_package"] == "test_org-my_extension"
def test_generate_extension_names_case_preservation():
"""Test that display name case is preserved."""
names = generate_extension_names("CamelCase Extension", "test-org")
assert names["display_name"] == "CamelCase Extension"
assert names["id"] == "test-org.camelcase-extension"
# Edge case tests
@pytest.mark.parametrize(
("edge_case",),
[
("",), # Empty string
(" ",), # Only spaces
("---",), # Only hyphens
("___",), # Only underscores
],
)
def test_empty_or_invalid_inputs(edge_case):
"""Test inputs that become empty or invalid after processing."""
with pytest.raises(ExtensionNameError):
generate_extension_names(edge_case, "test-org")
def test_minimal_valid_input():
"""Test minimal valid input."""
names = generate_extension_names("A Extension", "test-org")
assert names["id"] == "test-org.a-extension"
assert names["backend_package"] == "test_org-a_extension"
def test_numbers_handling():
"""Test handling of numbers in names."""
names = generate_extension_names("Tool 123 v2", "test-org")
assert names["id"] == "test-org.tool-123-v2"
assert names["backend_package"] == "test_org-tool_123_v2"
def test_manual_technical_name_override():
"""Test using manual technical name instead of auto-generated."""
display_name = "My Awesome Chart Builder Pro"
publisher = "acme"
technical_name = "chart-builder" # Much shorter than display name
# Create names using manual technical name
names = generate_extension_names(display_name, publisher, technical_name)
# Verify technical names come from provided technical name, not display name
assert (
names["display_name"] == "My Awesome Chart Builder Pro"
) # Display name preserved
assert names["publisher"] == "acme"
assert names["name"] == "chart-builder" # Technical name used
assert names["id"] == "acme.chart-builder" # Composite ID
assert names["mf_name"] == "acme_chartBuilder" # Module Federation format
assert names["backend_package"] == "acme-chart_builder" # Collision-safe
assert names["backend_path"] == "superset_extensions.acme.chart_builder"
assert names["backend_entry"] == "superset_extensions.acme.chart_builder.entrypoint"
def test_generate_names_uses_suggested_technical_names():
"""Test that generate_extension_names can auto-suggest technical names."""
display_name = "Hello World"
publisher = "test-org"
# Generated names should use suggested technical name generation
names = generate_extension_names(display_name, publisher)
# Verify the technical name was suggested from display name
assert names["name"] == "hello-world"
assert names["id"] == "test-org.hello-world"
# Verify other names were generated from the technical name and publisher
assert names["mf_name"] == get_module_federation_name(
"test-org", "hello-world"
) # "testOrg_helloWorld"
assert names["backend_package"] == "test_org-hello_world"
# Module Federation name should use underscore format with camelCase
assert names["mf_name"] == "testOrg_helloWorld"
+94 -33
View File
@@ -42,8 +42,15 @@ def jinja_env(templates_dir):
def template_context():
"""Default template context for testing."""
return {
"id": "test_extension",
"name": "Test Extension",
"publisher": "test-org",
"name": "test-extension",
"display_name": "Test Extension",
"id": "test-org.test-extension",
"npm_name": "@test-org/test-extension",
"mf_name": "testOrg_testExtension",
"backend_package": "test_org-test_extension",
"backend_path": "superset_extensions.test_org.test_extension",
"backend_entry": "superset_extensions.test_org.test_extension.entrypoint",
"version": "0.1.0",
"license": "Apache-2.0",
"include_frontend": True,
@@ -64,8 +71,9 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
parsed = json.loads(rendered)
# Verify basic fields
assert parsed["id"] == "test_extension"
assert parsed["name"] == "Test Extension"
assert parsed["publisher"] == "test-org"
assert parsed["name"] == "test-extension"
assert parsed["displayName"] == "Test Extension"
assert parsed["version"] == "0.1.0"
assert parsed["license"] == "Apache-2.0"
assert parsed["permissions"] == []
@@ -75,14 +83,26 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
frontend = parsed["frontend"]
assert "contributions" in frontend
assert "moduleFederation" in frontend
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
assert frontend["contributions"] == {
"commands": [],
"views": {},
"menus": {},
"editors": [],
}
assert frontend["moduleFederation"] == {
"exposes": ["./index"],
"name": "testOrg_testExtension",
}
# Verify backend section exists
assert "backend" in parsed
backend = parsed["backend"]
assert backend["entryPoints"] == ["test_extension.entrypoint"]
assert backend["files"] == ["backend/src/test_extension/**/*.py"]
assert backend["entryPoints"] == [
"superset_extensions.test_org.test_extension.entrypoint"
]
assert backend["files"] == [
"backend/src/superset_extensions/test_org/test_extension/**/*.py"
]
@pytest.mark.unit
@@ -127,7 +147,7 @@ def test_frontend_package_json_template_renders_correctly(jinja_env, template_co
parsed = json.loads(rendered)
# Verify basic package info
assert parsed["name"] == "test_extension"
assert parsed["name"] == "@test-org/test-extension"
assert parsed["version"] == "0.1.0"
assert parsed["license"] == "Apache-2.0"
assert parsed["private"] is True
@@ -161,7 +181,7 @@ def test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_c
rendered = template.render(template_context)
# Basic content verification (without full TOML parsing)
assert "test_extension" in rendered
assert "test_org-test_extension" in rendered
assert "0.1.0" in rendered
assert "Apache-2.0" in rendered
@@ -169,19 +189,36 @@ def test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_c
# Template Rendering with Different Parameters Tests
@pytest.mark.unit
@pytest.mark.parametrize(
"id_,name",
"publisher,technical_name,display_name",
[
("simple_extension", "Simple Extension"),
("MyExtension123", "My Extension 123"),
("complex_extension_name_123", "Complex Extension Name 123"),
("ext", "Ext"),
("test-org", "simple-extension", "Simple Extension"),
("acme", "my-extension-123", "My Extension 123"),
("company", "complex-extension-name-123", "Complex Extension Name 123"),
("pub", "ext", "Ext"),
],
)
def test_template_rendering_with_different_ids(jinja_env, id_, name):
"""Test templates render correctly with various extension ids/names."""
def test_template_rendering_with_different_ids(
jinja_env, publisher, technical_name, display_name
):
"""Test templates render correctly with various publisher/name combinations."""
from superset_extensions_cli.utils import (
get_module_federation_name,
kebab_to_snake_case,
)
publisher_snake = kebab_to_snake_case(publisher)
name_snake = kebab_to_snake_case(technical_name)
context = {
"id": id_,
"name": name,
"publisher": publisher,
"name": technical_name,
"display_name": display_name,
"id": f"{publisher}.{technical_name}",
"npm_name": f"@{publisher}/{technical_name}",
"mf_name": get_module_federation_name(publisher, technical_name),
"backend_package": f"{publisher_snake}-{name_snake}",
"backend_path": f"superset_extensions.{publisher_snake}.{name_snake}",
"backend_entry": f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint",
"version": "1.0.0",
"license": "MIT",
"include_frontend": True,
@@ -193,23 +230,28 @@ def test_template_rendering_with_different_ids(jinja_env, id_, name):
rendered = template.render(context)
parsed = json.loads(rendered)
assert parsed["id"] == id_
assert parsed["name"] == name
assert parsed["backend"]["entryPoints"] == [f"{id_}.entrypoint"]
assert parsed["backend"]["files"] == [f"backend/src/{id_}/**/*.py"]
assert parsed["publisher"] == publisher
assert parsed["name"] == technical_name
assert parsed["displayName"] == display_name
assert parsed["backend"]["entryPoints"] == [
f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint"
]
assert parsed["backend"]["files"] == [
f"backend/src/superset_extensions/{publisher_snake}/{name_snake}/**/*.py"
]
# Test package.json template
template = jinja_env.get_template("frontend/package.json.j2")
rendered = template.render(context)
parsed = json.loads(rendered)
assert parsed["name"] == id_
assert parsed["name"] == f"@{publisher}/{technical_name}"
# Test pyproject.toml template
template = jinja_env.get_template("backend/pyproject.toml.j2")
rendered = template.render(context)
assert id_ in rendered
assert f"{publisher_snake}-{name_snake}" in rendered
@pytest.mark.unit
@@ -217,8 +259,12 @@ def test_template_rendering_with_different_ids(jinja_env, id_, name):
def test_template_rendering_with_different_versions(jinja_env, version):
"""Test templates render correctly with various version formats."""
context = {
"id": "test_ext",
"name": "Test Extension",
"publisher": "test-pub",
"name": "test-ext",
"display_name": "Test Extension",
"id": "test-pub.test-ext",
"npm_name": "@test-pub/test-ext",
"mf_name": "testPub_testExt",
"version": version,
"license": "Apache-2.0",
"include_frontend": True,
@@ -246,8 +292,15 @@ def test_template_rendering_with_different_versions(jinja_env, version):
def test_template_rendering_with_different_licenses(jinja_env, license_type):
"""Test templates render correctly with various license types."""
context = {
"id": "test_ext",
"name": "Test Extension",
"publisher": "test-pub",
"name": "test-ext",
"display_name": "Test Extension",
"id": "test-pub.test-ext",
"npm_name": "@test-pub/test-ext",
"mf_name": "testPub_testExt",
"backend_package": "test_pub-test_ext",
"backend_path": "superset_extensions.test_pub.test_ext",
"backend_entry": "superset_extensions.test_pub.test_ext.entrypoint",
"version": "1.0.0",
"license": license_type,
"include_frontend": True,
@@ -312,8 +365,15 @@ def test_template_context_edge_cases(jinja_env):
"""Test template rendering with edge case contexts."""
# Test with minimal context
minimal_context = {
"id": "minimal",
"name": "Minimal",
"publisher": "min",
"name": "minimal",
"display_name": "Minimal",
"id": "min.minimal",
"npm_name": "@min/minimal",
"mf_name": "min_minimal",
"backend_package": "min-minimal",
"backend_path": "superset_extensions.min.minimal",
"backend_entry": "superset_extensions.min.minimal.entrypoint",
"version": "1.0.0",
"license": "MIT",
"include_frontend": False,
@@ -325,7 +385,8 @@ def test_template_context_edge_cases(jinja_env):
parsed = json.loads(rendered)
# Should still be valid JSON with basic fields
assert parsed["id"] == "minimal"
assert parsed["name"] == "Minimal"
assert parsed["publisher"] == "min"
assert parsed["name"] == "minimal"
assert parsed["displayName"] == "Minimal"
assert "frontend" not in parsed
assert "backend" not in parsed
+16 -2
View File
@@ -16,6 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
// Register TypeScript require hook so ESLint can load .ts plugin files
require('tsx/cjs');
const packageConfig = require('./package.json');
const importCoreModules = [];
@@ -148,7 +152,7 @@ module.exports = {
// Custom Superset rules
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
'i18n-strings/no-template-vars': 'error',
// Core ESLint overrides for Superset
'no-console': 'warn',
@@ -195,7 +199,7 @@ module.exports = {
'**/jest.setup.js',
'**/webpack.config.js',
'**/webpack.config.*.js',
'**/.eslintrc.js',
'**/.eslintrc*.js',
],
optionalDependencies: false,
},
@@ -245,6 +249,16 @@ module.exports = {
// Lodash
'lodash/import-scope': [2, 'member'],
// React effect best practices
'react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change':
'error',
'react-you-might-not-need-an-effect/no-chain-state-updates': 'error',
'react-you-might-not-need-an-effect/no-event-handler': 'error',
'react-you-might-not-need-an-effect/no-derived-state': 'error',
// Storybook
'storybook/prefer-pascal-case': 'error',
// File progress
'file-progress/activate': 1,
+4 -1
View File
@@ -17,6 +17,9 @@
* under the License.
*/
// Register TypeScript require hook so ESLint can load .ts plugin files
require('tsx/cjs');
/**
* MINIMAL ESLint config - ONLY for rules OXC doesn't support
* This config is designed to be run alongside OXC linter
@@ -66,7 +69,7 @@ module.exports = {
// Custom Superset plugins
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
'i18n-strings/no-template-vars': 'error',
'file-progress/activate': 1,
// Explicitly turn off all other rules to avoid conflicts
+1 -1
View File
@@ -115,7 +115,7 @@ module.exports = {
}),
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgen: getAbsolutePath('react-docgen-typescript'),
},
framework: {
+70 -42
View File
@@ -2808,9 +2808,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"peer": true,
"dependencies": {
@@ -2818,6 +2818,10 @@
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
@@ -2838,15 +2842,15 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@@ -4545,6 +4549,22 @@
"dev": true,
"peer": true
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"peer": true
},
"node_modules/fastq": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
@@ -7506,15 +7526,15 @@
}
},
"node_modules/schema-utils/node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@@ -7849,16 +7869,16 @@
}
},
"node_modules/table/node_modules/ajv": {
"version": "8.6.3",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz",
"integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@@ -8341,6 +8361,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
@@ -10848,9 +10869,9 @@
}
},
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"peer": true,
"requires": {
@@ -10870,15 +10891,15 @@
},
"dependencies": {
"ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
"require-from-string": "^2.0.2"
}
},
"json-schema-traverse": {
@@ -12142,6 +12163,12 @@
"dev": true,
"peer": true
},
"fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"peer": true
},
"fastq": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
@@ -14235,15 +14262,15 @@
},
"dependencies": {
"ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
"require-from-string": "^2.0.2"
}
},
"ajv-keywords": {
@@ -14500,16 +14527,16 @@
},
"dependencies": {
"ajv": {
"version": "8.6.3",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz",
"integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
"require-from-string": "^2.0.2"
}
},
"json-schema-traverse": {
@@ -14828,6 +14855,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"peer": true,
"requires": {
"punycode": "^2.1.0"
+6 -1
View File
@@ -42,6 +42,11 @@ module.exports = {
'<rootDir>/packages/.*/lib',
'<rootDir>/plugins/.*/esm',
'<rootDir>/plugins/.*/lib',
// Ignore build artifacts that contain duplicate package.json or mock files
'<rootDir>/storybook-static',
// Ignore duplicate __mocks__ at package root level (e.g., packages/superset-ui-core/__mocks__)
// but not test __mocks__ directories (e.g., packages/superset-ui-core/test/__mocks/)
'<rootDir>/packages/[^/]+/__mocks__',
],
setupFilesAfterEnv: ['<rootDir>/spec/helpers/setup.ts'],
snapshotSerializers: ['@emotion/jest/serializer'],
@@ -64,7 +69,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect)',
],
preset: 'ts-jest',
transform: {
+28 -3
View File
@@ -1,6 +1,14 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["import", "react", "jest", "jsx-a11y", "typescript", "unicorn"],
"plugins": [
"import",
"react",
"jest",
"jsx-a11y",
"typescript",
"unicorn",
"oxc"
],
"env": {
"browser": true,
"node": true,
@@ -34,7 +42,8 @@
"no-unused-vars": "off",
"no-undef": "error",
"no-prototype-builtins": "off",
"no-unsafe-optional-chaining": "off",
"no-unsafe-optional-chaining": "error",
"no-constant-binary-expression": "error",
"no-import-assign": "off",
"no-promise-executor-return": "off",
@@ -72,6 +81,12 @@
"as-needed",
{ "requireReturnForObjectLiteral": false }
],
"no-useless-constructor": "error",
"no-else-return": "error",
"no-array-constructor": "error",
"no-new-wrappers": "error",
"no-regex-spaces": "error",
"no-object-constructor": "error",
// === Import plugin rules ===
"import/no-unresolved": "error",
@@ -136,7 +151,7 @@
"react/jsx-no-bind": "off",
"react/jsx-props-no-spreading": "off",
"react/jsx-boolean-value": ["error", "never", { "always": [] }],
"react/jsx-no-duplicate-props": ["error", { "ignoreCase": true }],
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/jsx-pascal-case": ["error", { "allowAllCaps": true, "ignore": [] }],
"react/jsx-uses-vars": "error",
@@ -254,6 +269,16 @@
// === Unicorn rules (bonus coverage) ===
"unicorn/no-new-array": "error",
"unicorn/no-invalid-remove-event-listener": "error",
"unicorn/no-useless-length-check": "error",
"unicorn/no-length-as-slice-end": "error",
"unicorn/no-useless-spread": "error",
"unicorn/no-thenable": "error",
"unicorn/escape-case": "error",
"unicorn/prefer-array-flat-map": "error",
"unicorn/prefer-array-some": "error",
"unicorn/throw-new-error": "error",
"unicorn/prefer-negative-index": "error",
"unicorn/prefer-math-trunc": "error",
"unicorn/filename-case": "off",
"unicorn/prevent-abbreviations": "off",
"unicorn/no-null": "off",
+1585 -2168
View File
File diff suppressed because it is too large Load Diff
+28 -28
View File
@@ -109,6 +109,7 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.5",
@@ -159,12 +160,11 @@
"classnames": "^2.2.5",
"content-disposition": "^1.0.1",
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.19",
"dom-to-image-more": "^3.7.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.3",
"fuse.js": "^7.1.0",
@@ -183,14 +183,14 @@
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.17.23",
"mapbox-gl": "^3.18.1",
"markdown-to-jsx": "^9.7.3",
"markdown-to-jsx": "^9.7.6",
"match-sorter": "^6.3.4",
"memoize-one": "^5.2.1",
"pretty-ms": "^9.3.0",
"mousetrap": "^1.6.5",
"mustache": "^4.2.0",
"nanoid": "^5.1.6",
"ol": "^7.5.2",
"ol": "^10.8.0",
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^17.0.2",
@@ -201,7 +201,7 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^17.0.2",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.0.2",
"react-intersection-observer": "^10.0.3",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -257,20 +257,20 @@
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.58.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-actions": "^8.6.15",
"@storybook/addon-controls": "^8.6.15",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/addon-links": "^8.6.15",
"@storybook/addon-mdx-gfm": "^8.6.15",
"@storybook/components": "^8.6.15",
"@storybook/preview-api": "^8.6.15",
"@storybook/react": "^8.6.15",
"@storybook/react-webpack5": "^8.6.15",
"@storybook/addon-actions": "^8.6.17",
"@storybook/addon-controls": "^8.6.17",
"@storybook/addon-essentials": "^8.6.17",
"@storybook/addon-links": "^8.6.17",
"@storybook/addon-mdx-gfm": "^8.6.17",
"@storybook/components": "^8.6.17",
"@storybook/preview-api": "^8.6.17",
"@storybook/react": "^8.6.17",
"@storybook/react-webpack5": "^8.6.17",
"@storybook/test": "^8.6.15",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.11",
"@swc/plugin-emotion": "^14.5.0",
"@swc/core": "^1.15.13",
"@swc/plugin-emotion": "^14.6.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -283,7 +283,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.2.3",
"@types/node": "^25.3.0",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -304,12 +304,12 @@
"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.9.19",
"baseline-browser-mapping": "^2.10.0",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^13.0.1",
"cross-env": "^10.1.0",
"css-loader": "^7.1.3",
"css-loader": "^7.1.4",
"css-minimizer-webpack-plugin": "^7.0.4",
"eslint": "^8.56.0",
"eslint-config-prettier": "^7.2.0",
@@ -317,6 +317,7 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-cypress": "^3.6.0",
"eslint-plugin-file-progress": "^1.5.0",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest-dom": "^5.5.0",
@@ -326,9 +327,9 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.8.5",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.15.4",
"eslint-plugin-testing-library": "^7.16.0",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
@@ -341,12 +342,12 @@
"jest-html-reporter": "^4.3.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^28.0.0",
"jsdom": "^28.1.0",
"lerna": "^8.2.3",
"lightningcss": "^1.31.1",
"mini-css-extract-plugin": "^2.10.0",
"open-cli": "^8.0.0",
"oxlint": "^1.42.0",
"oxlint": "^1.50.0",
"po2json": "^0.4.5",
"prettier": "3.8.1",
"prettier-plugin-packagejson": "^3.0.0",
@@ -357,7 +358,7 @@
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.5.0",
"storybook": "8.6.15",
"storybook": "8.6.17",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.3.16",
@@ -368,13 +369,13 @@
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.3",
"webpack": "^5.105.0",
"wait-on": "^9.0.4",
"webpack": "^5.105.2",
"webpack-bundle-analyzer": "^5.2.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.3",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.3.3",
"webpack-sources": "^3.3.4",
"webpack-visualizer-plugin2": "^2.0.0"
},
"peerDependencies": {
@@ -390,7 +391,6 @@
},
"overrides": {
"core-js": "^3.38.1",
"d3-color": "^3.1.0",
"puppeteer": "^22.4.1",
"remark-gfm": "^3.0.1",
"underscore": "^1.13.7",
@@ -4144,15 +4144,6 @@
"node": ">= 8"
}
},
"node_modules/@react-icons/all-files": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz",
"integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==",
"peer": true,
"peerDependencies": {
"react": "*"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -4186,7 +4177,6 @@
"integrity": "sha512-zi2DJ2cTpgR1HugPX3yBHJAaBo7XYhodgZqj0BsKNMoexrLvHyPYsN+cw5xXFE1Q1ZyeKtQBB5m41+CKKfwQYw==",
"peer": true,
"dependencies": {
"@react-icons/all-files": "^4.1.0",
"@superset-ui/core": "0.18.25",
"lodash": "^4.17.15",
"prop-types": "^15.7.2"
@@ -21330,13 +21320,6 @@
"fastq": "^1.6.0"
}
},
"@react-icons/all-files": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz",
"integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==",
"peer": true,
"requires": {}
},
"@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -21367,7 +21350,6 @@
"integrity": "sha512-zi2DJ2cTpgR1HugPX3yBHJAaBo7XYhodgZqj0BsKNMoexrLvHyPYsN+cw5xXFE1Q1ZyeKtQBB5m41+CKKfwQYw==",
"peer": true,
"requires": {
"@react-icons/all-files": "^4.1.0",
"@superset-ui/core": "0.18.25",
"lodash": "^4.17.15",
"prop-types": "^15.7.2"
@@ -18,7 +18,7 @@
"@babel/preset-typescript": "^7.28.5",
"typescript": "^5.0.0",
"@emotion/styled": "^11.14.1",
"@types/lodash": "^4.17.23",
"@types/lodash": "^4.17.24",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^12.1.5",
@@ -33,7 +33,7 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"nanoid": "^5.0.9",
"react": "^17.0.2",
@@ -294,6 +294,10 @@ export interface EditorKeyword {
meta?: string;
/** Sorting priority; higher scores appear first in the completion list */
score?: number;
/** Short supplementary text such as a type signature or the full value when truncated */
detail?: string;
/** Longer documentation content as an HTML string, shown in a documentation popup */
documentation?: string;
}
/**
@@ -493,25 +497,25 @@ export interface EditorProviderUnregisteredEvent {
* Register an editor provider for specific languages.
* When an extension registers an editor, it replaces the default for those languages.
*
* @param contribution The editor contribution metadata from extension.json
* The contribution metadata (name, languages, description) is read from the
* extension's manifest (extension.json), so only the contribution ID and
* component are needed at registration time.
*
* @param id The editor contribution ID declared in extension.json
* @param component The React component implementing EditorProps
* @returns A Disposable to unregister the provider
*
* @example
* ```typescript
* const disposable = registerEditorProvider(
* {
* id: 'acme.monaco-sql',
* name: 'Monaco SQL Editor',
* languages: ['sql'],
* },
* 'acme.monaco-sql',
* MonacoSQLEditor
* );
* context.disposables.push(disposable);
* ```
*/
export declare function registerEditorProvider(
contribution: EditorContribution,
id: string,
component: EditorComponent,
): Disposable;
@@ -0,0 +1,326 @@
/**
* 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.
*/
// Contribution configuration interfaces
export interface CommandConfig {
id: string;
title: string;
icon?: string;
execute: () => void | Promise<void>;
when?: () => boolean;
onActivate?: () => void; // Called when command is registered
onDeactivate?: () => void; // Called when command is unregistered
}
export interface ViewConfig {
id: string;
title: string;
location: string; // e.g., "dashboard.tabs", "explore.panels"
component: React.ComponentType;
when?: () => boolean;
onActivate?: () => void; // Called when view is registered
onDeactivate?: () => void; // Called when view is unregistered
}
export interface EditorConfig {
id: string;
name: string;
mimeTypes: string[];
component: React.ComponentType;
onActivate?: () => void; // Called when editor is registered
onDeactivate?: () => void; // Called when editor is unregistered
}
export interface MenuConfig {
id: string;
title: string;
location: string; // e.g., "navbar.items", "context.menus"
action: () => void | Promise<void>;
when?: () => boolean;
onActivate?: () => void; // Called when menu item is registered
onDeactivate?: () => void; // Called when menu item is unregistered
}
// Extension metadata attached to defined contributions
export interface ContributionMetadata {
type: 'command' | 'view' | 'editor' | 'menu';
id: string;
config: any;
}
// Handle returned by define* functions for cleanup
export interface ContributionHandle<T = any> {
config: T;
dispose: () => void;
__contributionMeta__: ContributionMetadata;
}
// Extension context interface (simplified)
export interface ExtensionContext {
registerCommand: (config: CommandConfig) => () => void;
registerViewProvider: (
id: string,
component: React.ComponentType,
) => () => void;
registerEditor: (config: EditorConfig) => () => void;
registerMenu: (config: MenuConfig) => () => void;
}
// Global registry for auto-registration
let _context: ExtensionContext | null = null;
const _pendingContributions: ContributionHandle[] = [];
/**
* Set the extension context for auto-registration.
* Called automatically by the extension loader.
*/
export function setExtensionContext(context: ExtensionContext): void {
_context = context;
// Auto-register any pending contributions
for (const handle of _pendingContributions) {
_registerContribution(handle);
}
_pendingContributions.length = 0;
}
/**
* Internal: Auto-register a single contribution
*/
function _registerContribution(handle: ContributionHandle): void {
if (!_context) {
_pendingContributions.push(handle);
return;
}
const { config, __contributionMeta__ } = handle;
let disposeFn: () => void;
// Call onActivate callback if provided
const typedConfig = config as
| CommandConfig
| ViewConfig
| EditorConfig
| MenuConfig;
if (typedConfig.onActivate) {
typedConfig.onActivate();
}
switch (__contributionMeta__.type) {
case 'command':
disposeFn = _context.registerCommand(config as CommandConfig);
break;
case 'view':
const viewConfig = config as ViewConfig;
disposeFn = _context.registerViewProvider(
viewConfig.id,
viewConfig.component,
);
break;
case 'editor':
disposeFn = _context.registerEditor(config as EditorConfig);
break;
case 'menu':
disposeFn = _context.registerMenu(config as MenuConfig);
break;
default:
throw new Error(
`Unknown contribution type: ${__contributionMeta__.type}`,
);
}
// Wrap dispose function to call onDeactivate
const originalDispose = disposeFn;
handle.dispose = () => {
if (typedConfig.onDeactivate) {
typedConfig.onDeactivate();
}
originalDispose();
};
}
// Type augmentation to add metadata to functions
declare global {
interface Function {
__contributionMeta__?: ContributionMetadata;
}
}
/**
* Define a command contribution.
*
* Commands are actions that can be triggered from various UI elements
* like menus, toolbars, or keyboard shortcuts.
*
* Auto-registers when extension context is available.
*
* @param config Command configuration
* @returns Handle with config and dispose function
*/
export function defineCommand<T extends CommandConfig>(
config: T,
): ContributionHandle<T> {
// Store metadata for webpack plugin discovery
const metadata: ContributionMetadata = {
type: 'command',
id: config.id,
config,
};
// Attach metadata to the execute function for runtime validation
if (config.execute) {
config.execute.__contributionMeta__ = metadata;
}
// Create handle that auto-registers
const handle: ContributionHandle<T> = {
config,
dispose: () => {}, // Will be set by _registerContribution
__contributionMeta__: metadata,
};
// Auto-register immediately or queue for later
_registerContribution(handle);
return handle;
}
/**
* Define a view contribution.
*
* Views are UI components that can be embedded in various locations
* throughout the Superset interface.
*
* Auto-registers when extension context is available.
*
* @param config View configuration
* @returns Handle with config and dispose function
*/
export function defineView<T extends ViewConfig>(
config: T,
): ContributionHandle<T> {
// Store metadata for webpack plugin discovery
const metadata: ContributionMetadata = {
type: 'view',
id: config.id,
config,
};
// Attach metadata to the component for runtime validation
if (config.component) {
(config.component as any).__contributionMeta__ = metadata;
}
// Create handle that auto-registers
const handle: ContributionHandle<T> = {
config,
dispose: () => {}, // Will be set by _registerContribution
__contributionMeta__: metadata,
};
// Auto-register immediately or queue for later
_registerContribution(handle);
return handle;
}
/**
* Define an editor contribution.
*
* Editors provide custom editing interfaces for specific MIME types
* in SQL Lab and other contexts.
*
* Auto-registers when extension context is available.
*
* @param config Editor configuration
* @returns Handle with config and dispose function
*/
export function defineEditor<T extends EditorConfig>(
config: T,
): ContributionHandle<T> {
// Store metadata for webpack plugin discovery
const metadata: ContributionMetadata = {
type: 'editor',
id: config.id,
config,
};
// Attach metadata to the component for runtime validation
if (config.component) {
(config.component as any).__contributionMeta__ = metadata;
}
// Create handle that auto-registers
const handle: ContributionHandle<T> = {
config,
dispose: () => {}, // Will be set by _registerContribution
__contributionMeta__: metadata,
};
// Auto-register immediately or queue for later
_registerContribution(handle);
return handle;
}
/**
* Define a menu contribution.
*
* Menus add items to various menu locations throughout the interface.
*
* Auto-registers when extension context is available.
*
* @param config Menu configuration
* @returns Handle with config and dispose function
*/
export function defineMenu<T extends MenuConfig>(
config: T,
): ContributionHandle<T> {
// Store metadata for webpack plugin discovery
const metadata: ContributionMetadata = {
type: 'menu',
id: config.id,
config,
};
// Attach metadata to the action function for runtime validation
if (config.action) {
config.action.__contributionMeta__ = metadata;
}
// Create handle that auto-registers
const handle: ContributionHandle<T> = {
config,
dispose: () => {}, // Will be set by _registerContribution
__contributionMeta__: metadata,
};
// Auto-register immediately or queue for later
_registerContribution(handle);
return handle;
}
/**
* Internal: Clear the contribution registry (for testing)
*/
export function _clearContributionRegistry(): void {
_pendingContributions.length = 0;
_context = null;
}
@@ -17,5 +17,6 @@
* under the License.
*/
export * from './api';
export * from './extensions';
export * from './ui';
export * from './utils';
@@ -19,6 +19,7 @@
import { Alert as AntdAlert } from 'antd';
import type { PropsWithChildren } from 'react';
import type { AlertProps as AntdAlertProps } from 'antd/es/alert';
import { t } from '../../translation';
/**
* Props for the Alert component, extending Ant Design's AlertProps
@@ -74,7 +75,7 @@ export const Alert = (props: AlertProps) => {
type={type}
showIcon={showIcon}
closable={closable}
message={children || 'Default message'}
message={children || t('Default message')}
description={description}
{...rest}
/>
@@ -23,9 +23,9 @@ import '@fontsource/inter/200.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/fira-code/400.css';
import '@fontsource/fira-code/500.css';
import '@fontsource/fira-code/600.css';
import '@fontsource/ibm-plex-mono/400.css';
import '@fontsource/ibm-plex-mono/500.css';
import '@fontsource/ibm-plex-mono/600.css';
/* eslint-enable import/extensions */
import { css, useTheme, Global } from '@emotion/react';
@@ -502,7 +502,7 @@ test('Theme base theme integration works with real-world Superset base theme con
colorSuccess: '#5ac189',
colorInfo: '#66bcfe',
fontFamily: "'Inter', Helvetica, Arial",
fontFamilyCode: "'Fira Code', 'Courier New', monospace",
fontFamilyCode: "'IBM Plex Mono', 'Courier New', monospace",
},
};
@@ -116,6 +116,7 @@ export interface SupersetSpecificTokens {
fontWeightNormal: string;
fontWeightLight: string;
fontWeightStrong: number;
fontWeightBold: string;
// Brand-related
brandIconMaxWidth: number;
@@ -47,6 +47,15 @@ export function getFontSize(theme: SupersetTheme, size?: FontSizeKey): string {
return String(theme[key] || theme.fontSize);
}
/**
* Check if the current theme is dark mode based on background color
* @param theme - Theme tokens from useTheme()
* @returns true if theme is dark, false if light
*/
export function isThemeDark(theme: SupersetTheme): boolean {
return tinycolor(theme.colorBgContainer).isDark();
}
/**
* Get color variants for a given color type from theme tokens
* @param theme - Theme tokens from useTheme()
@@ -111,15 +120,6 @@ export function getColorVariants(
};
}
/**
* Check if the current theme is dark mode based on background color
* @param theme - Theme tokens from useTheme()
* @returns true if theme is dark, false if light
*/
export function isThemeDark(theme: SupersetTheme): boolean {
return tinycolor(theme.colorBgContainer).isDark();
}
/**
* Check if a theme configuration results in a dark theme
* @param config - The theme configuration to check
@@ -17,4 +17,5 @@
* under the License.
*/
export { default as isBlank } from './isBlank';
export { default as logging } from './logging';
@@ -0,0 +1,59 @@
/**
* 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 isBlank from './isBlank';
test('returns true for null', () => {
expect(isBlank(null)).toBe(true);
});
test('returns true for undefined', () => {
expect(isBlank(undefined)).toBe(true);
});
test('returns true for empty string', () => {
expect(isBlank('')).toBe(true);
});
test('returns true for whitespace-only strings', () => {
expect(isBlank(' ')).toBe(true);
expect(isBlank(' ')).toBe(true);
expect(isBlank('\t')).toBe(true);
expect(isBlank('\n')).toBe(true);
expect(isBlank(' \t\n ')).toBe(true);
});
test('returns false for non-empty strings', () => {
expect(isBlank('hello')).toBe(false);
expect(isBlank(' hello ')).toBe(false);
});
test('returns true for NaN', () => {
expect(isBlank(NaN)).toBe(true);
});
test('returns false for numbers', () => {
expect(isBlank(0)).toBe(false);
expect(isBlank(50)).toBe(false);
expect(isBlank(-1)).toBe(false);
});
test('returns false for booleans', () => {
expect(isBlank(true)).toBe(false);
expect(isBlank(false)).toBe(false);
});
@@ -0,0 +1,28 @@
/**
* 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 { isEmpty, isNaN, isNil, isString, trim } from 'lodash';
/**
* Checks if a value is null, undefined, NaN, or a whitespace-only string.
*/
export default function isBlank(value: unknown): boolean {
return (
isNil(value) || isNaN(value) || (isString(value) && isEmpty(trim(value)))
);
}
@@ -17,7 +17,8 @@
* under the License.
*/
const console = window.console || {};
const console =
typeof window !== 'undefined' ? window.console || {} : globalThis.console;
const log = console.log || (() => {});
const logger = {
@@ -0,0 +1,253 @@
/**
* 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 * as React from 'react';
import {
defineCommand,
defineView,
defineEditor,
defineMenu,
setExtensionContext,
_clearContributionRegistry,
} from '../src/extensions';
// Mock extension context for testing
const mockContext = {
registerCommand: jest.fn(() => jest.fn()),
registerViewProvider: jest.fn(() => jest.fn()),
registerEditor: jest.fn(() => jest.fn()),
registerMenu: jest.fn(() => jest.fn()),
};
describe('Extension Contributions', () => {
beforeEach(() => {
_clearContributionRegistry();
jest.clearAllMocks();
});
describe('defineCommand', () => {
test('should create command contribution with metadata', () => {
const command = defineCommand({
id: 'test-command',
title: 'Test Command',
icon: 'TestIcon',
execute: async () => console.log('executed'),
});
expect(command.config.id).toBe('test-command');
expect(command.config.title).toBe('Test Command');
expect(command.config.icon).toBe('TestIcon');
expect(command.__contributionMeta__).toBeDefined();
expect(command.__contributionMeta__.type).toBe('command');
expect(command.__contributionMeta__.id).toBe('test-command');
});
test('should auto-register when context is available', () => {
setExtensionContext(mockContext);
const command = defineCommand({
id: 'auto-command',
title: 'Auto Command',
execute: async () => {},
});
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
});
test('should call lifecycle callbacks', () => {
const onActivate = jest.fn();
const onDeactivate = jest.fn();
setExtensionContext(mockContext);
const command = defineCommand({
id: 'lifecycle-command',
title: 'Lifecycle Command',
execute: async () => {},
onActivate,
onDeactivate,
});
expect(onActivate).toHaveBeenCalled();
// Test disposal
command.dispose();
expect(onDeactivate).toHaveBeenCalled();
});
});
describe('defineView', () => {
test('should create view contribution with metadata', () => {
const TestComponent = () => React.createElement('div', null, 'Test');
const view = defineView({
id: 'test-view',
title: 'Test View',
location: 'sqllab.panels',
component: TestComponent,
});
expect(view.config.id).toBe('test-view');
expect(view.config.title).toBe('Test View');
expect(view.config.location).toBe('sqllab.panels');
expect(view.config.component).toBe(TestComponent);
expect(view.__contributionMeta__).toBeDefined();
expect(view.__contributionMeta__.type).toBe('view');
});
test('should auto-register when context is available', () => {
const TestComponent = () => React.createElement('div', null, 'Test');
setExtensionContext(mockContext);
defineView({
id: 'auto-view',
title: 'Auto View',
location: 'dashboard.tabs',
component: TestComponent,
});
expect(mockContext.registerViewProvider).toHaveBeenCalledWith(
'auto-view',
TestComponent
);
});
});
describe('defineEditor', () => {
test('should create editor contribution with metadata', () => {
const EditorComponent = () => React.createElement('textarea');
const editor = defineEditor({
id: 'test-editor',
name: 'Test Editor',
mimeTypes: ['text/x-sql'],
component: EditorComponent,
});
expect(editor.config.id).toBe('test-editor');
expect(editor.config.name).toBe('Test Editor');
expect(editor.config.mimeTypes).toEqual(['text/x-sql']);
expect(editor.__contributionMeta__).toBeDefined();
expect(editor.__contributionMeta__.type).toBe('editor');
});
test('should auto-register when context is available', () => {
const EditorComponent = () => React.createElement('textarea');
setExtensionContext(mockContext);
const editor = defineEditor({
id: 'auto-editor',
name: 'Auto Editor',
mimeTypes: ['text/plain'],
component: EditorComponent,
});
expect(mockContext.registerEditor).toHaveBeenCalledWith(editor.config);
});
});
describe('defineMenu', () => {
test('should create menu contribution with metadata', () => {
const menu = defineMenu({
id: 'test-menu',
title: 'Test Menu',
location: 'navbar.items',
action: () => console.log('clicked'),
});
expect(menu.config.id).toBe('test-menu');
expect(menu.config.title).toBe('Test Menu');
expect(menu.config.location).toBe('navbar.items');
expect(menu.__contributionMeta__).toBeDefined();
expect(menu.__contributionMeta__.type).toBe('menu');
});
test('should auto-register when context is available', () => {
setExtensionContext(mockContext);
const menu = defineMenu({
id: 'auto-menu',
title: 'Auto Menu',
location: 'context.menus',
action: () => {},
});
expect(mockContext.registerMenu).toHaveBeenCalledWith(menu.config);
});
});
describe('Auto-registration system', () => {
test('should queue contributions when no context is set', () => {
const command = defineCommand({
id: 'queued-command',
title: 'Queued Command',
execute: async () => {},
});
// Should not be registered yet
expect(mockContext.registerCommand).not.toHaveBeenCalled();
// Set context - should register queued contributions
setExtensionContext(mockContext);
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
});
test('should handle disposal correctly', () => {
const mockDispose = jest.fn();
mockContext.registerCommand.mockReturnValue(mockDispose);
setExtensionContext(mockContext);
const command = defineCommand({
id: 'dispose-command',
title: 'Dispose Command',
execute: async () => {},
});
// Dispose should call the returned cleanup function
command.dispose();
expect(mockDispose).toHaveBeenCalled();
});
test('should handle mixed contribution types', () => {
setExtensionContext(mockContext);
const command = defineCommand({
id: 'mixed-command',
title: 'Mixed Command',
execute: async () => {},
});
const view = defineView({
id: 'mixed-view',
title: 'Mixed View',
location: 'explore.panels',
component: () => React.createElement('div', null, 'Mixed'),
});
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
expect(mockContext.registerViewProvider).toHaveBeenCalledWith(
'mixed-view',
view.config.component
);
});
});
});
@@ -25,7 +25,6 @@
],
"dependencies": {
"@apache-superset/core": "*",
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.23"
},
@@ -19,14 +19,20 @@
import { ReactNode } from 'react';
import { t } from '@apache-superset/core';
import { JsonValue } from '@superset-ui/core';
import { Radio } from '@superset-ui/core/components';
import { Radio, Tooltip, TooltipPlacement } from '@superset-ui/core/components';
import { ControlHeader } from '../../components/ControlHeader';
// [value, label]
export type RadioButtonOption = [
JsonValue,
Exclude<ReactNode, null | undefined | boolean>,
];
export interface RadioButtonOptionObject {
value: JsonValue;
label: Exclude<ReactNode, null | undefined | boolean>;
disabled?: boolean;
tooltip?: string;
tooltipPlacement?: TooltipPlacement;
}
export type RadioButtonOption =
| [JsonValue, Exclude<ReactNode, null | undefined | boolean>]
| RadioButtonOptionObject;
export interface RadioButtonControlProps {
label?: ReactNode;
@@ -34,7 +40,17 @@ export interface RadioButtonControlProps {
options: RadioButtonOption[];
hovered?: boolean;
value?: JsonValue;
onChange: (opt: RadioButtonOption[0]) => void;
onChange: (opt: JsonValue) => void;
}
function normalizeOption(option: RadioButtonOption): RadioButtonOptionObject {
if (Array.isArray(option)) {
return {
value: option[0],
label: option[1],
};
}
return option;
}
export default function RadioButtonControl({
@@ -43,7 +59,9 @@ export default function RadioButtonControl({
onChange,
...props
}: RadioButtonControlProps) {
const currentValue = initialValue || options[0][0];
const normalizedOptions = options.map(normalizeOption);
const currentValue = initialValue || normalizedOptions[0].value;
return (
<div>
<div
@@ -55,29 +73,52 @@ export default function RadioButtonControl({
value={currentValue}
onChange={e => onChange(e.target.value)}
>
{options.map(([val, label]) => (
<Radio.Button
// role="tab"
key={JSON.stringify(val)}
value={val}
aria-label={typeof label === 'string' ? label : undefined}
id={`tab-${val}`}
type="button"
aria-selected={val === currentValue}
className={`btn btn-default ${
val === currentValue ? 'active' : ''
}`}
onClick={e => {
e.currentTarget?.focus();
onChange(val);
}}
>
{label}
</Radio.Button>
))}
{normalizedOptions.map(
({
value: val,
label,
disabled = false,
tooltip,
tooltipPlacement = 'top',
}) => {
const button = (
<Radio.Button
key={JSON.stringify(val)}
value={val}
disabled={disabled}
aria-label={typeof label === 'string' ? label : undefined}
id={`tab-${val}`}
type="button"
aria-selected={val === currentValue}
className={`btn btn-default ${
val === currentValue ? 'active' : ''
}`}
onClick={e => {
e.currentTarget?.focus();
onChange(val);
}}
>
{label}
</Radio.Button>
);
if (tooltip) {
return (
<Tooltip
key={JSON.stringify(val)}
title={tooltip}
placement={tooltipPlacement}
>
{button}
</Tooltip>
);
}
return button;
},
)}
</Radio.Group>
</div>
{/* accessibility begin */}
<div
aria-live="polite"
style={{
@@ -90,10 +131,10 @@ export default function RadioButtonControl({
>
{t(
'%s tab selected',
options.find(([val]) => val === currentValue)?.[1],
normalizedOptions.find(({ value: val }) => val === currentValue)
?.label,
)}
</div>
{/* accessibility end */}
</div>
);
}
@@ -62,18 +62,41 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
// Dynamically add axis-specific controls (rows and columns)
(['columns', 'rows'] as const).forEach(axisParam => {
const axis: 'rows' | 'columns' = axisParam;
const otherAxis: 'rows' | 'columns' = axis === 'rows' ? 'columns' : 'rows';
matrixifyControls[`matrixify_mode_${axis}`] = {
type: 'RadioButtonControl',
label: t(`Metrics / Dimensions`),
default: 'metrics',
options: [
['metrics', t('Metrics')],
['dimensions', t('Dimension members')],
],
default: axis === 'columns' ? 'metrics' : 'dimensions',
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) => isMatrixifyVisible(controls, axis),
mapStateToProps: ({ controls }) => {
const otherAxisControlName = `matrixify_mode_${otherAxis}`;
const otherAxisValue =
controls?.[otherAxisControlName]?.value ??
(otherAxis === 'columns' ? 'metrics' : 'dimensions');
const isMetricsDisabled = otherAxisValue === 'metrics';
return {
options: [
{
value: 'metrics',
label: t('Metrics'),
disabled: isMetricsDisabled,
tooltip: isMetricsDisabled
? t(
"Metrics can't be used for both rows and columns at the same time",
)
: undefined,
},
{ value: 'dimensions', label: t('Dimension members') },
],
};
},
rerender: [`matrixify_mode_${otherAxis}`, `matrixify_dimension_${axis}`],
};
matrixifyControls[`matrixify_${axis}`] = {
@@ -80,9 +80,9 @@ export const datePickerInAdhocFilterMixin: Pick<
// 2) there was a time filter in adhoc filters
if (
state?.controls?.time_range?.value ||
ensureIsArray(control.value).findIndex(
ensureIsArray(control.value).some(
(flt: any) => flt?.operator === 'TEMPORAL_RANGE',
) > -1
)
) {
return undefined;
}
@@ -443,14 +443,12 @@ const order_by_cols: SharedControlConfig<'SelectControl'> = {
default: [],
shouldMapStateToProps: () => true,
mapStateToProps: ({ datasource }) => ({
choices: (datasource?.columns || [])
.map(col =>
[true, false].map(asc => [
JSON.stringify([col.column_name, asc]),
`${getColumnLabel(col.column_name)} [${asc ? 'asc' : 'desc'}]`,
]),
)
.flat(),
choices: (datasource?.columns || []).flatMap(col =>
[true, false].map(asc => [
JSON.stringify([col.column_name, asc]),
`${getColumnLabel(col.column_name)} [${asc ? 'asc' : 'desc'}]`,
]),
),
}),
resetOnHide: false,
};
@@ -491,12 +491,16 @@ export type ConditionalFormattingConfig = {
toAllRow?: boolean;
toTextColor?: boolean;
useGradient?: boolean;
columnFormatting?: string;
objectFormatting?: ObjectFormattingEnum;
};
export type ColorFormatters = {
column: string;
toAllRow?: boolean;
toTextColor?: boolean;
columnFormatting?: string;
objectFormatting?: ObjectFormattingEnum;
getColorFromValue: (
value: number | string | boolean | null,
) => string | undefined;
@@ -614,6 +618,13 @@ export type ControlFormItemSpec<T extends ControlType = ControlType> = {
}
: {});
export enum ObjectFormattingEnum {
BACKGROUND_COLOR = 'BACKGROUND_COLOR',
TEXT_COLOR = 'TEXT_COLOR',
CELL_BAR = 'CELL_BAR',
ENTIRE_ROW = 'ENTIRE_ROW',
}
export enum ColorSchemeEnum {
Green = 'Green',
Red = 'Red',
@@ -17,6 +17,8 @@
* under the License.
*/
import memoizeOne from 'memoize-one';
import { isString, isBoolean } from 'lodash';
import { isBlank } from '@apache-superset/core';
import { addAlpha, DataRecord } from '@superset-ui/core';
import {
ColorFormatters,
@@ -254,6 +256,9 @@ export const getColorFunction = (
}
return (value: number | string | boolean | null) => {
if (isBlank(value) && operator !== Comparator.IsNull) {
return undefined;
}
const compareResult = comparatorFunction(value, columnValues);
if (compareResult === false) return undefined;
const { cutoffValue, extremeValue } = compareResult;
@@ -306,6 +311,8 @@ export const getColorFormatters = memoizeOne(
column: config?.column,
toAllRow: config?.toAllRow,
toTextColor: config?.toTextColor,
columnFormatting: config?.columnFormatting,
objectFormatting: config?.objectFormatting,
getColorFromValue: getColorFunction(
{ ...config, colorScheme: resolvedColorScheme },
data.map(row => row[config.column!] as number),
@@ -318,11 +325,3 @@ export const getColorFormatters = memoizeOne(
[],
) ?? [],
);
function isString(value: unknown) {
return typeof value === 'string';
}
function isBoolean(value: unknown) {
return typeof value === 'boolean';
}
@@ -0,0 +1,420 @@
/**
* 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 '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@superset-ui/core/spec';
import userEvent from '@testing-library/user-event';
import RadioButtonControl, {
RadioButtonControlProps,
RadioButtonOption,
} from '../../../src/shared-controls/components/RadioButtonControl';
const defaultProps: RadioButtonControlProps = {
label: 'Test Radio Control',
options: [
['option1', 'Option 1'],
['option2', 'Option 2'],
['option3', 'Option 3'],
],
onChange: jest.fn(),
};
const setup = (props: Partial<RadioButtonControlProps> = {}) =>
render(<RadioButtonControl {...defaultProps} {...props} />);
test('renders with array-based options (legacy format)', () => {
const { container } = setup();
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
});
test('renders with object-based options (new format)', () => {
const objectOptions: RadioButtonOption[] = [
{ value: 'opt1', label: 'Object Option 1' },
{ value: 'opt2', label: 'Object Option 2' },
{ value: 'opt3', label: 'Object Option 3' },
];
setup({ options: objectOptions });
expect(screen.getByText('Object Option 1')).toBeInTheDocument();
expect(screen.getByText('Object Option 2')).toBeInTheDocument();
expect(screen.getByText('Object Option 3')).toBeInTheDocument();
});
test('renders mixed array and object options', () => {
const mixedOptions: RadioButtonOption[] = [
['array1', 'Array Option'],
{ value: 'obj1', label: 'Object Option' },
];
setup({ options: mixedOptions });
expect(screen.getByText('Array Option')).toBeInTheDocument();
expect(screen.getByText('Object Option')).toBeInTheDocument();
});
test('defaults to first option when no value provided', () => {
const { container } = setup();
const firstButton = container.querySelector('#tab-option1');
expect(firstButton).toBeInTheDocument();
expect(firstButton).toHaveAttribute('aria-selected', 'true');
});
test('respects initial value prop', () => {
const { container } = setup({ value: 'option2' });
const secondButton = container.querySelector('#tab-option2');
expect(secondButton).toBeInTheDocument();
expect(secondButton).toHaveAttribute('aria-selected', 'true');
});
test('calls onChange when radio button is clicked', () => {
const onChange = jest.fn();
setup({ onChange });
const secondOption = screen.getByText('Option 2');
fireEvent.click(secondOption);
expect(onChange).toHaveBeenCalledWith('option2');
expect(onChange).toHaveBeenCalled();
});
test('handles multiple clicks correctly', () => {
const onChange = jest.fn();
setup({ onChange });
fireEvent.click(screen.getByText('Option 2'));
fireEvent.click(screen.getByText('Option 3'));
fireEvent.click(screen.getByText('Option 1'));
expect(onChange).toHaveBeenCalledWith('option2');
expect(onChange).toHaveBeenCalledWith('option3');
expect(onChange).toHaveBeenCalledWith('option1');
expect(onChange.mock.calls.length).toBeGreaterThanOrEqual(3);
});
test('disables specific options when disabled flag is set', () => {
const optionsWithDisabled: RadioButtonOption[] = [
{ value: 'opt1', label: 'Enabled Option' },
{ value: 'opt2', label: 'Disabled Option', disabled: true },
{ value: 'opt3', label: 'Another Enabled' },
];
const { container } = setup({ options: optionsWithDisabled });
const disabledButton = container.querySelector('#tab-opt2');
const enabledButton = container.querySelector('#tab-opt1');
expect(disabledButton).toHaveAttribute('disabled');
expect(enabledButton).not.toHaveAttribute('disabled');
});
test('disabled options do not trigger onChange when clicked', () => {
const onChange = jest.fn();
const optionsWithDisabled: RadioButtonOption[] = [
{ value: 'opt1', label: 'Enabled' },
{ value: 'opt2', label: 'Disabled', disabled: true },
];
const { container } = setup({ options: optionsWithDisabled, onChange });
const disabledButton = container.querySelector('#tab-opt2');
if (disabledButton) {
fireEvent.click(disabledButton);
}
expect(onChange).not.toHaveBeenCalled();
});
test('renders ControlHeader with label and description', () => {
const { container } = setup({
label: 'My Radio Control',
description: 'This is a helpful description',
});
const header = container.querySelector('.ControlHeader');
expect(header).toBeInTheDocument();
expect(screen.getByText('My Radio Control')).toBeInTheDocument();
});
test('aria-live region updates with current selection', () => {
const { container } = setup({ value: 'option1' });
const ariaLiveRegion = container.querySelector('[aria-live="polite"]');
expect(ariaLiveRegion).toBeInTheDocument();
expect(ariaLiveRegion?.textContent).toContain('Option 1');
});
test('aria-live region updates when selection changes', () => {
const { container, rerender } = setup({ value: 'option1' });
let ariaLiveRegion = container.querySelector('[aria-live="polite"]');
expect(ariaLiveRegion?.textContent).toContain('Option 1');
rerender(<RadioButtonControl {...defaultProps} value="option2" />);
ariaLiveRegion = container.querySelector('[aria-live="polite"]');
expect(ariaLiveRegion?.textContent).toContain('Option 2');
});
test('aria-live region is visually hidden but accessible', () => {
const { container } = setup();
const ariaLiveRegion = container.querySelector(
'[aria-live="polite"]',
) as HTMLElement;
expect(ariaLiveRegion).toBeInTheDocument();
expect(ariaLiveRegion?.style.position).toBe('absolute');
expect(ariaLiveRegion?.style.left).toBe('-9999px');
expect(ariaLiveRegion?.style.height).toBe('1px');
expect(ariaLiveRegion?.style.width).toBe('1px');
expect(ariaLiveRegion?.style.overflow).toBe('hidden');
});
test('renders tablist with correct aria-label when label is string', () => {
const { container } = setup({ label: 'String Label' });
const tablist = container.querySelector('[role="tablist"]');
expect(tablist).toHaveAttribute('aria-label', 'String Label');
});
test('tablist has no aria-label when label is not string', () => {
const { container } = setup({ label: <div>JSX Label</div> });
const tablist = container.querySelector('[role="tablist"]');
expect(tablist).not.toHaveAttribute('aria-label');
});
test('each radio button has correct aria-selected state', () => {
const { container } = setup({ value: 'option2' });
expect(container.querySelector('#tab-option1')).toHaveAttribute(
'aria-selected',
'false',
);
expect(container.querySelector('#tab-option2')).toHaveAttribute(
'aria-selected',
'true',
);
expect(container.querySelector('#tab-option3')).toHaveAttribute(
'aria-selected',
'false',
);
});
test('radio buttons have correct aria-label when label is string', () => {
setup();
const option1Button = screen.getByLabelText('Option 1');
expect(option1Button).toBeInTheDocument();
});
test('focuses button when clicked', () => {
const { container } = setup();
const button = container.querySelector('#tab-option2') as HTMLElement;
fireEvent.click(button);
expect(document.activeElement).toBe(button);
});
test('handles numeric values in options', () => {
const onChange = jest.fn();
const numericOptions: RadioButtonOption[] = [
[1, 'One'],
[2, 'Two'],
[3, 'Three'],
];
setup({ options: numericOptions, onChange });
fireEvent.click(screen.getByText('Two'));
expect(onChange).toHaveBeenCalledWith(2);
});
test('handles boolean values in options', () => {
const onChange = jest.fn();
const booleanOptions: RadioButtonOption[] = [
[true, 'True'],
[false, 'False'],
];
setup({ options: booleanOptions, onChange });
fireEvent.click(screen.getByText('False'));
expect(onChange).toHaveBeenCalledWith(false);
});
test('handles null values in options', () => {
const onChange = jest.fn();
const nullOptions: RadioButtonOption[] = [
[null, 'None'],
['value', 'Value'],
];
setup({ options: nullOptions, onChange });
fireEvent.click(screen.getByText('None'));
expect(onChange).toHaveBeenCalledWith(null);
});
test('generates unique IDs for options', () => {
const { container } = setup();
const button1 = container.querySelector('#tab-option1');
const button2 = container.querySelector('#tab-option2');
const button3 = container.querySelector('#tab-option3');
expect(button1).toBeInTheDocument();
expect(button2).toBeInTheDocument();
expect(button3).toBeInTheDocument();
});
test('applies active class to selected button', () => {
const { container } = setup({ value: 'option2' });
const activeButton = container.querySelector('#tab-option2');
expect(activeButton).toBeInTheDocument();
expect(activeButton).toHaveAttribute('aria-selected', 'true');
});
test('does not set aria-selected to true for unselected buttons', () => {
const { container } = setup({ value: 'option2' });
const inactiveButton1 = container.querySelector('#tab-option1');
const inactiveButton3 = container.querySelector('#tab-option3');
expect(inactiveButton1).toHaveAttribute('aria-selected', 'false');
expect(inactiveButton3).toHaveAttribute('aria-selected', 'false');
});
test('backward compatibility with legacy array format', () => {
const onChange = jest.fn();
const legacyOptions: RadioButtonOption[] = [
['val1', 'Label 1'],
['val2', 'Label 2'],
];
setup({ options: legacyOptions, onChange });
expect(screen.getByText('Label 1')).toBeInTheDocument();
expect(screen.getByText('Label 2')).toBeInTheDocument();
fireEvent.click(screen.getByText('Label 2'));
expect(onChange).toHaveBeenCalledWith('val2');
});
test('normalizeOption handles array format correctly', () => {
const arrayOption: RadioButtonOption = ['value', 'Label'];
const onChange = jest.fn();
setup({ options: [arrayOption], onChange });
expect(screen.getByText('Label')).toBeInTheDocument();
fireEvent.click(screen.getByText('Label'));
expect(onChange).toHaveBeenCalledWith('value');
});
test('normalizeOption handles object format correctly', () => {
const objectOption: RadioButtonOption = {
value: 'value',
label: 'Label',
disabled: false,
};
const onChange = jest.fn();
setup({ options: [objectOption], onChange });
expect(screen.getByText('Label')).toBeInTheDocument();
fireEvent.click(screen.getByText('Label'));
expect(onChange).toHaveBeenCalledWith('value');
});
test('handles empty options array gracefully', () => {
const { container } = setup({ options: [], value: 'default' });
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
});
test('renders with hovered prop', () => {
const { container } = setup({
label: 'Test',
description: 'Test description',
hovered: true,
});
expect(
container.querySelector('[data-test="info-tooltip-icon"]'),
).toBeInTheDocument();
});
test('renders tooltips for options with tooltip property', async () => {
const optionsWithTooltips: RadioButtonOption[] = [
{ value: 'opt1', label: 'Option 1', tooltip: 'Tooltip for option 1' },
{ value: 'opt2', label: 'Option 2' },
{ value: 'opt3', label: 'Option 3', tooltip: 'Tooltip for option 3' },
];
setup({ options: optionsWithTooltips });
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
const option1 = screen.getByText('Option 1');
userEvent.hover(option1);
await waitFor(() => {
expect(screen.getByText('Tooltip for option 1')).toBeInTheDocument();
});
userEvent.unhover(option1);
const option3 = screen.getByText('Option 3');
userEvent.hover(option3);
await waitFor(() => {
expect(screen.getByText('Tooltip for option 3')).toBeInTheDocument();
});
});
test('wraps disabled buttons with tooltip in span', () => {
const optionsWithDisabledTooltip: RadioButtonOption[] = [
{ value: 'opt1', label: 'Enabled with tooltip', tooltip: 'Tooltip text' },
{
value: 'opt2',
label: 'Disabled with tooltip',
disabled: true,
tooltip: 'Disabled tooltip',
},
];
const { container } = setup({ options: optionsWithDisabledTooltip });
const disabledButton = container.querySelector('#tab-opt2');
expect(disabledButton).toHaveAttribute('disabled');
expect(disabledButton?.parentElement?.tagName).toBe('SPAN');
});
@@ -506,6 +506,117 @@ test('getColorFunction IsNotNull', () => {
expect(colorFunction(null)).toBeUndefined();
});
test('getColorFunction returns undefined for null values on numeric comparators', () => {
const operators = [
{ operator: Comparator.LessThan, targetValue: 50 },
{ operator: Comparator.LessOrEqual, targetValue: 50 },
{ operator: Comparator.GreaterThan, targetValue: 50 },
{ operator: Comparator.GreaterOrEqual, targetValue: 50 },
{ operator: Comparator.Equal, targetValue: 50 },
{ operator: Comparator.NotEqual, targetValue: 50 },
];
operators.forEach(({ operator, targetValue }) => {
const colorFunction = getColorFunction(
{
operator,
targetValue,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(null)).toBeUndefined();
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
});
});
test('getColorFunction returns undefined for null values on Between comparators', () => {
const operators = [
Comparator.Between,
Comparator.BetweenOrEqual,
Comparator.BetweenOrLeftEqual,
Comparator.BetweenOrRightEqual,
];
operators.forEach(operator => {
const colorFunction = getColorFunction(
{
operator,
targetValueLeft: -10,
targetValueRight: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(null)).toBeUndefined();
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
});
});
test('getColorFunction returns undefined for null values on None operator', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(null)).toBeUndefined();
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
});
test('getColorFunction returns undefined for null values on string comparators', () => {
const operators = [
Comparator.BeginsWith,
Comparator.EndsWith,
Comparator.Containing,
Comparator.NotContaining,
];
operators.forEach(operator => {
const colorFunction = getColorFunction(
{
operator,
targetValue: 'test',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction(null)).toBeUndefined();
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
});
});
test('getColorFunction returns undefined for empty and whitespace string values', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction('' as unknown as number)).toBeUndefined();
expect(colorFunction(' ' as unknown as number)).toBeUndefined();
expect(colorFunction('\t' as unknown as number)).toBeUndefined();
});
test('getColorFunction IsNull still matches null values', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.IsNull,
targetValue: '',
colorScheme: '#FF0000',
column: 'isMember',
},
boolValues,
);
expect(colorFunction(null)).toEqual('#FF0000FF');
expect(colorFunction(true)).toBeUndefined();
});
test('correct column config', () => {
const columnConfig = [
{
@@ -77,8 +77,8 @@
"@types/react-table": "^7.7.20",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.23",
"@types/node": "^25.2.3",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.0",
"@types/prop-types": "^15.7.15",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
@@ -18,6 +18,7 @@
*/
import { memo, useMemo } from 'react';
import { t } from '@apache-superset/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { MatrixifyGridCell as GridCellData } from '../../types/matrixify';
import StatefulChart from '../StatefulChart';
@@ -84,7 +85,7 @@ interface MatrixifyGridCellProps {
// Simple No Data component for matrix cells
const MatrixNoDataComponent = () => {
const theme = useTheme();
return <NoDataMessage theme={theme}>No data</NoDataMessage>;
return <NoDataMessage theme={theme}>{t('No data')}</NoDataMessage>;
};
/**
@@ -19,6 +19,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { ParentSize } from '@visx/responsive';
import { t } from '@apache-superset/core';
import {
QueryFormData,
QueryData,
@@ -440,7 +441,7 @@ export default function StatefulChart(props: StatefulChartProps) {
textAlign: 'center',
}}
>
Error: {error.message}
{t('Error')}: {error.message}
</div>
);
@@ -42,6 +42,21 @@ test('renders SQLEditor', async () => {
});
});
test('SQLEditor uses fontFamilyCode from theme', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
const fontFamily = editorInstance?.getOption('fontFamily');
// Verify font family is set (not undefined) and contains a monospace font
expect(fontFamily).toBeDefined();
expect(fontFamily).toMatch(/mono|courier|consolas/i);
});
test('renders FullSQLEditor', async () => {
const { container } = render(<FullSQLEditor />);
@@ -114,7 +114,7 @@ export function AsyncAceEditor(
defaultMode,
defaultTheme,
defaultTabSize = 2,
fontFamily = 'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
fontFamily,
placeholder,
}: AsyncAceEditorOptions = {},
) {
@@ -171,6 +171,7 @@ export function AsyncAceEditor(
ref,
) {
const token = useTheme();
const editorFontFamily = fontFamily || token.fontFamilyCode;
const langTools = acequire('ace/ext/language_tools');
const setCompleters = useCallback(
@@ -436,7 +437,7 @@ export function AsyncAceEditor(
theme={theme}
tabSize={tabSize}
defaultValue={defaultValue}
setOptions={{ fontFamily }}
setOptions={{ fontFamily: editorFontFamily }}
{...props}
/>
</>

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