Compare commits

...

99 Commits

Author SHA1 Message Date
Claude Code
b304f33a9b test(databases): stabilize flaky SQLAlchemy-form visibility assertion
The form mounts inside DatabaseModal's animated tab pane, and
rc-motion's animation state in jsdom is nondeterministic: toBeVisible
intermittently times out (observed on unrelated PRs' jest shards) even
though the form is rendered. Assert on document presence instead, which
is what the tab-switch regression check actually needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:17:11 -07:00
marun
83d93b8b42 fix(view-query): Add ownState parameter to ViewQueryModal for query consistency (#35208)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-06 11:16:55 -07:00
Elizabeth Thompson
06f421ed4a fix(a11y): add aria-label to remove-sheet icon button in Google Sheets catalog (#41796) 2026-07-06 10:38:42 -07:00
dependabot[bot]
dc671ae44a chore(deps-dev): bump eslint from 10.5.0 to 10.6.0 in /superset-frontend (#41792)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:38:16 -07:00
dependabot[bot]
c49d01223c chore(deps-dev): bump webpack from 5.108.0 to 5.108.2 in /docs (#41786)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:37:37 -07:00
dependabot[bot]
69f052985f chore(deps-dev): bump prettier from 3.8.4 to 3.9.3 in /superset-frontend (#41784)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:37:23 -07:00
dependabot[bot]
f4cc871f8c chore(deps): bump js-yaml from 5.1.0 to 5.2.0 in /docs (#41783)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:37:09 -07:00
dependabot[bot]
2f11240339 chore(deps): bump query-string from 9.4.0 to 9.4.1 in /superset-frontend (#41782)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:36:55 -07:00
Mike Bridge
a7a051777c feat(charts): soft-delete and restore (#40129)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 10:07:04 -07:00
Enzo Martellucci
b4218136be feat(ag-grid): support Show summary in raw records mode (#41754) 2026-07-06 17:58:02 +02:00
Joe Li
fd8d7009a5 chore(mcp): drop legacy /superset prefix from dashboard URLs (#41758)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:57:08 -07:00
dependabot[bot]
beff76cb4d chore(deps): bump antd from 6.4.5 to 6.5.0 in /docs (#41788)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 21:27:14 +07:00
dependabot[bot]
d6b31376a5 chore(deps-dev): bump prettier from 3.8.4 to 3.9.1 in /docs (#41789)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 21:21:11 +07:00
dependabot[bot]
1575b83f96 chore(deps): bump react-arborist from 3.10.5 to 3.12.0 in /superset-frontend (#41793)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 21:19:50 +07:00
alex
83826839a0 fix(i18n): inject language pack into bootstrap to translate code-split chunks (#39357)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-05 21:15:40 -07:00
dependabot[bot]
a8ab5f973d chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.61.1 to 8.62.0 in /superset-frontend (#41577)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-05 15:55:31 -07:00
Elizabeth Thompson
7cdfe3ffe7 fix(a11y): add aria-label to CRON schedule input (#41769)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-07-05 15:01:00 -07:00
EUbaldiEC
9a24d42d65 fix(chart): Fix heatmap charts when filtering down to one row/colum (#40738)
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-05 14:24:33 -07:00
Evan Rusackas
3303d4e09e chore(deps): bump lodash-es to ^4.18.1 across all frontend workspaces (#41774)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-07-05 21:28:58 +07:00
hayden
8229c0183d perf(explore): reuse chart query data in Results tab (#38165)
Co-authored-by: devops-sewhan-kim <sewhan.kim@a-bly.com>
2026-07-04 23:27:57 -07:00
Viktor Högberg
ea40b87e2f fix(drill-detail): drill to detail by a null value dimension should use NULL_STRING (#41678) 2026-07-04 23:18:07 -07:00
innovark
dc739e4fb9 fix(ui): standardize card kebab menu behavior (#41741) 2026-07-04 23:15:33 -07:00
RISHAV DEWAN
a687e87b2c fix(dashboard): use theme colorBorder token for chart tile borders (#41731) 2026-07-04 23:11:51 -07:00
Durgaprasad M L
d91c5cf09b fix(dashboard): prevent dashboard crash when applying grain filters (#41700) (#41746) 2026-07-04 23:07:11 -07:00
Durgaprasad M L
3ac19470ec fix(embedded): register AG Grid modules for view-as-table modal (#41632) 2026-07-04 23:06:13 -07:00
Đỗ Trọng Hải
687fafd424 build(dev-deps): replace deprecated minimizer deps with recommendation from Webpack doc (#41756) 2026-07-05 08:58:34 +07:00
Beto Dealmeida
8fcc0f8b48 fix(semantic layers): start/end ranges (#41590)
Co-authored-by: Joe Li <joe@preset.io>
2026-07-04 16:46:26 -07:00
yousoph
e08c2c12da fix(native-filters): persist created/pasted default values in select filter (#40984)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-07-04 16:44:29 -07:00
Joe Li
1e50316bcc chore(reports): deprecate Slack v1 and harden Slack v2 tests (#39914)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-04 16:41:39 -07:00
Elizabeth Thompson
3e152d9bb7 fix(views): add new_target to deprecated explore_json_data endpoint (#41771) 2026-07-04 15:01:42 -07:00
Pawan
004c401c97 refactor(dashboard): rename supersetCanCSV to supersetCanDownload (#24290) (#39118)
Co-authored-by: jaymasiwal <jaymasiwal@users.noreply.github.com>
2026-07-04 13:06:14 -07:00
dependabot[bot]
a9aabdaedf chore(deps): bump gunicorn from 25.3.0 to 26.0.0 (#41761)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-04 10:03:16 -07:00
Đỗ Trọng Hải
210389478b fix(test): change regex for RTL to retrieve button used for filter removal (#41767)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-04 09:58:30 -07:00
dependabot[bot]
43f2816240 chore(deps-dev): update ydb-sqlglot-plugin requirement from >=0.2.5 to >=0.2.8 (#41764)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-04 15:04:59 +07:00
dependabot[bot]
c3fe0a40eb chore(deps-dev): bump mysqlclient from 2.2.6 to 2.2.8 (#41760)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-04 14:53:22 +07:00
dependabot[bot]
71ac6c64e2 chore(deps): bump rison from 2.0.0 to 2.0.1 (#41762)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-04 14:52:53 +07:00
dependabot[bot]
81a437826f chore(deps-dev): update kylinpy requirement from <2.9,>=2.8.1 to >=2.8.4,<2.9 (#41765)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-04 14:52:36 +07:00
dependabot[bot]
fd1f313b30 chore(deps): bump flask-appbuilder from 5.2.1 to 5.2.2 (#41766)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-04 14:36:33 +07:00
Evan Rusackas
b23cef136e fix(i18n): un-translate do-not-translate tokens in es/fi/th catalogs (#41652)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:37:33 -07:00
Beto Dealmeida
681275077b fix(semantic layers): time comparison with 1 row (#41554) 2026-07-03 14:39:15 -07:00
Evan Rusackas
d7ad7fbb49 fix(i18n): repair dropped placeholders and reduced-plural forms across catalogs (#41722)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:33:26 -07:00
Evan Rusackas
59d35d16ce feat(i18n): adopt French translation improvements from #41688 (#41752)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Jean Pommier <jean.pommier@pi-geosolutions.fr>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:07:27 -07:00
Evan Rusackas
4e2160079a feat(i18n): backfill Arabic (ar) translations (AI-generated, needs review) (#41705)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:40:35 -07:00
Evan Rusackas
ceeba01305 fix(i18n): correct mistranslated entries flagged in review (pl/pt_BR/sl/sk/fi) (#41707)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:40:18 -07:00
Evan Rusackas
6d7344750f feat(i18n): backfill Chinese (Simplified) (zh) translations (AI-generated, needs review) (#41708)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:40:01 -07:00
Evan Rusackas
7d90684f93 feat(i18n): backfill Chinese (Traditional) (zh_TW) translations (AI-generated, needs review) (#41709)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:39:44 -07:00
Evan Rusackas
e7b825e26b feat(i18n): backfill Portuguese (pt) translations (AI-generated, needs review) (#41710)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:39:27 -07:00
Evan Rusackas
b6f8267ed2 feat(i18n): backfill Italian (it) translations (AI-generated, needs review) (#41712)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:39:10 -07:00
Evan Rusackas
d443dd17b9 feat(i18n): backfill Korean (ko) translations (AI-generated, needs review) (#41713)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:37:50 -07:00
Elizabeth Thompson
2702113d99 fix(a11y): add aria-label to VizTypeGallery search clear icon (#41681) 2026-07-03 10:39:20 -07:00
Elizabeth Thompson
0b14f1c226 fix(a11y): use aria-label instead of non-functional alt prop on filter icons (#41742) 2026-07-03 10:39:04 -07:00
dependabot[bot]
114c258145 chore(deps-dev): bump webpack from 5.107.2 to 5.108.0 in /superset-frontend (#41733)
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: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-07-03 23:58:39 +07:00
dependabot[bot]
9f8ff1e87f chore(deps): bump @deck.gl/mapbox from 9.3.4 to 9.3.5 in /superset-frontend (#41735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-03 21:15:13 +07:00
dependabot[bot]
2b30605e3c chore(deps): bump baseline-browser-mapping from 2.10.38 to 2.10.40 in /docs (#41736)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-03 21:14:41 +07:00
dependabot[bot]
d93098f853 chore(deps-dev): bump baseline-browser-mapping from 2.10.38 to 2.10.40 in /superset-frontend (#41737)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-03 21:14:06 +07:00
dependabot[bot]
eaf6daa7eb chore(deps-dev): bump webpack from 5.107.2 to 5.108.0 in /docs (#41734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-03 21:11:57 +07:00
Evan Rusackas
0e6c5838e4 fix(deck.gl): apply categorical scatterplot colors in Multiple Layers chart (#41490)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
2026-07-03 14:23:29 +02:00
Maxime Beauchemin
4f37e955b5 fix(plugin-chart-echarts): prevent trendline stroke clipping at chart edges (#37918)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-07-03 10:59:13 +02:00
Evan Rusackas
46a153d17e feat(i18n): backfill Persian (Farsi) (fa) translations (AI-generated, needs review) (#41701)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:58:21 -07:00
anamitraadhikari
6d22697cba feat(theming): make core components fully configurable via theme tokens (#40985)
Co-authored-by: aadhikari <aadhikari@apple.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-07-02 17:55:48 -07:00
dependabot[bot]
7cbdc726e3 chore(deps-dev): bump @storybook/addon-links from 10.4.4 to 10.4.6 in /superset-frontend (#41366)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 17:54:54 -07:00
dependabot[bot]
ceadce234b chore(deps-dev): bump @storybook/react-webpack5 from 10.4.4 to 10.4.6 in /superset-frontend (#41371)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 17:34:53 -07:00
Evan Rusackas
47bc3e2dc1 fix: correct Security menu case for MySQL deployments (#40527)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-07-02 17:23:31 -07:00
Evan Rusackas
49fcaf2420 feat(i18n): backfill Turkish (tr) translations (AI-generated, needs review) (#41702)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:21:17 -07:00
Enzo Martellucci
55088e10da feat(database-modal): add validation loading state and duplicate name check (#36880)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-02 16:52:13 -07:00
Mike Bridge
bdc610c572 fix(db): use a private engine for prequery connections to avoid listener race (#41642)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-07-02 16:30:09 -07:00
Evan Rusackas
a151edeff3 feat(i18n): backfill Dutch (nl) translations (AI-generated, needs review) (#41704)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:27:24 -07:00
Elizabeth Thompson
d8832c382d fix(views): emit deprecated-endpoint log warning once per endpoint per process (#41286) 2026-07-02 16:10:37 -07:00
Elizabeth Thompson
5bbab86a07 fix(schemas): rename deprecated query fields regardless of falsy values (#41263) 2026-07-02 16:10:10 -07:00
Yuriy Krasilnikov
ab0e77c1cb fix(embedded): allow guest users to sort by visible columns (#37371)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:50:56 -07:00
Joe Li
a30846881b test(ci): stabilize master checks (#41650) 2026-07-02 15:32:06 -07:00
Elizabeth Thompson
22c3f56d0a fix(redshift): suppress unavoidable pkg_resources deprecation warning (#41691) 2026-07-02 15:01:23 -07:00
Evan Rusackas
66bf81b997 feat(i18n): backfill Russian (ru) translations (AI-generated, needs review) (#41649)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:59:15 -07:00
Evan Rusackas
03703843b7 feat(i18n): backfill French (fr) translations (AI-generated, needs review) (#41655)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:57:58 -07:00
Evan Rusackas
f61d6d8b84 feat(i18n): backfill Māori (mi) translations (AI-generated, needs review) (#41656)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:57:32 -07:00
Evan Rusackas
7eb93c60a3 feat(i18n): backfill Catalan (ca) translations (AI-generated, needs review) (#41657)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:57:19 -07:00
Evan Rusackas
9e08770291 feat(i18n): backfill Slovenian (sl) translations (AI-generated, needs review) (#41658)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:57:05 -07:00
Evan Rusackas
358493a2c5 feat(i18n): backfill Brazilian Portuguese (pt_BR) translations (AI-generated, needs review) (#41659)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:56:31 -07:00
Evan Rusackas
83965b4be8 chore(ci): correct codeql-action version pin comment to v4.36.2 (#41693)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:56:00 -07:00
Evan Rusackas
f9bcf189c9 chore(ci): suppress zizmor adhoc-packages on GHA validator install (#41694)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:55:49 -07:00
Evan Rusackas
1c74185a71 chore(ci): correct codeql-action version comment to match pinned SHA (#41695)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:55:40 -07:00
Mehmet Salih Yavuz
8bf3933972 fix(dashboard): show a not-found state for a deleted dashboard (#41686) 2026-07-02 22:15:25 +03:00
yousoph
19e94855a1 fix(explore): prevent Results FilterInput from stealing focus during remount (#41100)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 11:33:17 -07:00
Brian Maina
139df20cde fix(i18n): update German security menu translations (#41587) 2026-07-03 01:13:14 +07:00
Imad Helal
4c193d4dbc feat(i18n): wrap description strings in translation function (#41626) 2026-07-03 00:44:25 +07:00
Evan Rusackas
aa40934e7f fix(i18n): compile fuzzy translations into the backend .mo files (#41648)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:43:13 -07:00
jack
6c2c814b5c fix(dashboard): not filterable column now not emitting cross-filters in table charts (#30827)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-02 10:42:53 -07:00
Evan Rusackas
9769380d6d feat(i18n): backfill Polish (pl) translations (AI-generated, needs review) (#41660) 2026-07-03 00:40:28 +07:00
dependabot[bot]
be29d877d2 chore(deps-dev): bump @storybook/addon-docs from 10.4.5 to 10.4.6 in /superset-frontend (#41375)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 10:37:26 -07:00
dependabot[bot]
e3b2992d6e chore(deps): update lodash-es requirement from ^4.17.21 to ^4.18.1 in /superset-frontend/packages/superset-ui-core (#41565)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 10:37:05 -07:00
Joe Li
c1bd45f561 fix(ci): allow showtime to check out fork PR code under checkout v7 (#41643) 2026-07-03 00:31:43 +07:00
Đỗ Trọng Hải
7214e9f9f6 build(dev-deps): upgrade Storybook to v10 in docs subproject (#41679)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-02 10:31:01 -07:00
Đỗ Trọng Hải
d7e2f18d00 fix(dockerfile): allow GH auth-less fetch of uv when building Superset image locally (#41682) 2026-07-03 00:30:03 +07:00
Luis Carbonell
6309d08d59 feat(helm): standardize to Kubernetes recommended labels (app.kubernetes.io/*) (#39350) 2026-07-03 00:26:35 +07:00
dependabot[bot]
afebdd58d1 chore(deps): bump docusaurus-theme-openapi-docs from 5.0.2 to 5.1.0 in /docs (#41669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 10:25:41 -07:00
Mike Bridge
be46d65e3b fix(dao): SQL-faithful NULL and non-string handling in LIKE-family operators (#41653)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-07-02 10:04:35 -07:00
Evan Rusackas
2992d7b4c8 feat(database): add databricks oauth support (#41421)
Co-authored-by: fabian_zse <fabian@zalando.de>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:26:08 -07:00
dependabot[bot]
80344852b7 chore(deps): bump docusaurus-plugin-openapi-docs from 5.0.2 to 5.1.0 in /docs (#41671)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 22:32:18 +07:00
dependabot[bot]
8210904e95 chore(deps): bump nanoid from 5.1.15 to 5.1.16 in /superset-frontend (#41673)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-07-02 22:31:16 +07:00
258 changed files with 80463 additions and 19030 deletions

View File

@@ -64,7 +64,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -75,6 +75,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:${{matrix.language}}"

View File

@@ -37,8 +37,9 @@ jobs:
node-version: "20"
- name: Install Dependencies
# Versions are pinned to avoid ad-hoc, unpinned package installs
# (zizmor adhoc-packages). Bump deliberately when upgrading.
# Versions are pinned to avoid ad-hoc, unpinned package installs.
# Bump deliberately when upgrading.
# zizmor: ignore[adhoc-packages] - @action-validator is a global CLI tool installed to validate the repo's workflows; a global CLI install has no application manifest/lockfile context, and the versions are pinned above
run: npm install -g @action-validator/core@0.6.0 @action-validator/cli@0.6.0
- name: Run Script

View File

@@ -2,7 +2,8 @@ name: 🎪 Superset Showtime
# Ultra-simple: just sync on any PR state change
on:
# zizmor: ignore[dangerous-triggers] - required to react to PR label changes; this workflow does not check out or execute PR-provided code
# zizmor: ignore[dangerous-triggers] - required to react to PR label changes; PR code is
# only checked out and built after the maintainer-authorization gate (write/admin actors)
pull_request_target:
types: [labeled, unlabeled, synchronize, closed]
@@ -156,6 +157,10 @@ jobs:
with:
ref: ${{ steps.check.outputs.target_sha }}
persist-credentials: false
# Building fork PR code is Showtime's purpose: deploys are gated on the
# maintainer-authorization step above (write/admin actors only), so this
# checkout is an explicit, authorized opt-in rather than an automatic one.
allow-unsafe-pr-checkout: true
- name: Setup Docker Environment (only if build needed)
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'

View File

@@ -120,7 +120,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash
# Some bash scripts needed throughout the layers
COPY --chmod=755 docker/*.sh /app/docker/
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN pip install --no-cache-dir --upgrade uv
# Using uv as it's faster/simpler than pip
RUN uv venv /app/.venv
@@ -141,7 +141,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY superset/translations/ /app/translations_mo/
RUN if [ "${BUILD_TRANSLATIONS}" = "true" ]; then \
pybabel compile -d /app/translations_mo | true; \
pybabel compile --use-fuzzy -d /app/translations_mo || true; \
fi; \
rm -f /app/translations_mo/*/*/*.[po,json]

View File

@@ -79,6 +79,19 @@ When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audien
The git SHA and build number surfaced in the "About" section, the bootstrap payload, and the public `/version` endpoint are now only included for admin users by default; the release version string is still shown to everyone. To expose the build details to all users (the previous behavior), set the `SUPERSET_EXPOSE_BUILD_DETAILS` environment variable (or `EXPOSE_BUILD_DETAILS_TO_USERS = True` in `superset_config.py`).
### Helm chart adopts Kubernetes recommended labels (breaking upgrade)
The Helm chart now labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. Because a Deployment's `spec.selector.matchLabels` is immutable, `helm upgrade` against an existing release will fail with a `field is immutable` error.
To upgrade, delete the affected workloads (which selector labels changed) before upgrading, then run the upgrade so they are recreated with the new labels:
```bash
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
helm upgrade <release-name> superset/superset
```
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
### Pivot table First/Last aggregations follow data order
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.
@@ -289,6 +302,28 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
### Soft delete and restore for charts
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/chart/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If charts are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live charts in all lists, lookups, and relationship loads (including dashboards that contained them). The `POST /<uuid>/restore` endpoint and the `chart_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the chart (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from all list, detail, and lookup endpoints. Charts in this state are excluded from default queries and from relationship loads (e.g. `dashboard.slices`).
**Operational notes:** a report schedule whose target chart is soft-deleted now fails its runs with an explicit error ("The chart this report targets was deleted...") until the chart is restored or the report re-pointed — chart deletion is blocked while a report references the chart, but a validate/commit race or a flag toggle can still produce this state. Dashboards **preserve** their membership rows for soft-deleted charts: saving a dashboard does not sever a trashed member, and restoring the chart re-attaches it to its dashboards.
**New endpoint** — `POST /api/v1/chart/<uuid>/restore` clears `deleted_at` and returns the chart to active state. Requires `can_write on Chart` and ownership of the row (or admin). Soft-deleted charts can also be surfaced in the list endpoint via the new `chart_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to charts they own — the same audience that can restore them.
**Permissions migration:** existing role grants of `can_write on Chart` cover the new restore endpoint automatically; no role migration is required.
**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_slices_deleted_at`) to the `slices` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `slices` table (INSERT/UPDATE/DELETE are queued while the index builds; reads are unaffected) on large Postgres deployments. MySQL InnoDB builds the index online (no blocking).
**Rollback note:** if the application code is rolled back after charts have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted charts silently become live, active charts with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) *before* downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted chart's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all out-of-archive references (`dashboard_slices` junctions, `report.chart_id`, tag rows). The operation is permission-gated: non-owners get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
- [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` still works via the legacy path. Grant your Slack bot the `channels:read` and `groups:read` scopes so existing `Slack` recipients can be auto-upgraded to `SlackV2` on next send. Operators who explicitly override the flag to `False`, or whose Slack bot is missing those scopes, will see deprecation warnings while text-only sends continue through the legacy path.
### Soft delete and restore for dashboards
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.

View File

@@ -332,15 +332,28 @@ cd superset-frontend
npm run build-translation
# Backend
pybabel compile -d superset/translations
pybabel compile --use-fuzzy -d superset/translations
```
`--use-fuzzy` includes `#, fuzzy` entries in the compiled `.mo` files. Superset
serves fuzzy translations on purpose: the frontend build (`po2json --fuzzy`)
already includes them, `flask fab babel-compile` (used by the release images)
compiles with `-f`, and the production `Dockerfile` compiles with `--use-fuzzy`
as well. This keeps machine-generated (and other draft) translations visible in
the UI rather than falling back to English while they await review.
### Backfilling missing translations with AI
For languages with many untranslated strings, the repo includes a script that
uses Claude AI to generate draft translations for any missing entries. All
AI-generated strings are marked `#, fuzzy` and tagged with an attribution
comment so that human reviewers know they need to be checked before merging.
comment so that human reviewers know they need to be checked.
Note that `#, fuzzy` marks a translation as *needing review*, not as *withheld*:
both the frontend and backend builds serve fuzzy entries (see [Applying
translations](#applying-translations) above), so an AI-generated string is shown
in the UI as soon as it is built and deployed. Reviewers should verify each
entry and remove the `#, fuzzy` flag to promote it to a confirmed translation.
#### Prerequisites

View File

@@ -749,7 +749,7 @@ const config: Config = {
showReadingTime: true,
// Please change this to your repo.
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/blog/',
'https://github.com/apache/superset/tree/master/docs',
},
theme: {
customCss: require.resolve('./src/styles/custom.css'),

View File

@@ -58,25 +58,15 @@
"@fontsource/inter": "^5.2.8",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^8.6.18",
"@storybook/blocks": "^8.6.15",
"@storybook/channels": "^8.6.18",
"@storybook/client-logger": "^8.6.18",
"@storybook/components": "^8.6.18",
"@storybook/core": "^8.6.18",
"@storybook/core-events": "^8.6.18",
"@storybook/csf": "^0.1.13",
"@storybook/docs-tools": "^8.6.18",
"@storybook/preview-api": "^8.6.18",
"@storybook/theming": "^8.6.15",
"@storybook/addon-docs": "^10.4.5",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.43",
"antd": "^6.4.5",
"baseline-browser-mapping": "^2.10.38",
"antd": "^6.5.0",
"baseline-browser-mapping": "^2.10.40",
"caniuse-lite": "^1.0.30001799",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
"js-yaml": "^5.1.0",
"docusaurus-plugin-openapi-docs": "^5.1.0",
"docusaurus-theme-openapi-docs": "^5.1.0",
"js-yaml": "^5.2.0",
"js-yaml-loader": "^1.2.2",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
@@ -88,7 +78,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^8.6.18",
"storybook": "^10.4.5",
"swagger-ui-react": "^5.32.8",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
@@ -107,10 +97,10 @@
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.7.0",
"prettier": "^3.8.4",
"prettier": "^3.9.1",
"typescript": "~6.0.3",
"typescript-eslint": "^8.62.0",
"webpack": "^5.107.2"
"webpack": "^5.108.2"
},
"browserslist": {
"production": [

View File

@@ -168,60 +168,6 @@ export default function webpackExtendPlugin(): Plugin<void> {
__dirname,
'../../superset-frontend/packages/superset-core/src',
),
// Add proper Storybook aliases
'@storybook/blocks': path.resolve(
__dirname,
'../node_modules/@storybook/blocks',
),
'@storybook/components': path.resolve(
__dirname,
'../node_modules/@storybook/components',
),
'@storybook/theming': path.resolve(
__dirname,
'../node_modules/@storybook/theming',
),
'@storybook/client-logger': path.resolve(
__dirname,
'../node_modules/@storybook/client-logger',
),
'@storybook/core-events': path.resolve(
__dirname,
'../node_modules/@storybook/core-events',
),
// Add internal Storybook aliases
'storybook/internal/components': path.resolve(
__dirname,
'../node_modules/@storybook/components',
),
'storybook/internal/theming': path.resolve(
__dirname,
'../node_modules/@storybook/theming',
),
'storybook/internal/client-logger': path.resolve(
__dirname,
'../node_modules/@storybook/client-logger',
),
'storybook/internal/csf': path.resolve(
__dirname,
'../node_modules/@storybook/csf',
),
'storybook/internal/preview-api': path.resolve(
__dirname,
'../node_modules/@storybook/preview-api',
),
'storybook/internal/docs-tools': path.resolve(
__dirname,
'../node_modules/@storybook/docs-tools',
),
'storybook/internal/core-events': path.resolve(
__dirname,
'../node_modules/@storybook/core-events',
),
'storybook/internal/channels': path.resolve(
__dirname,
'../node_modules/@storybook/channels',
),
},
},
};

View File

@@ -122,9 +122,9 @@
},
{
"name": "ALERT_REPORT_SLACK_V2",
"default": false,
"default": true,
"lifecycle": "testing",
"description": "Enables Slack V2 integration for Alerts and Reports"
"description": "Enables Slack V2 integration for Alerts and Reports. Defaults to True; the legacy Slack v1 path is deprecated and will be removed in the next major release. Operators must grant the Slack bot both the `channels:read` and `groups:read` scopes so existing v1 recipients can be auto-upgraded on their next send. Without those scopes, file uploads fail (Slack retired the `files.upload` endpoint in 2025) and only text-only `chat_postMessage` sends will continue to work via the legacy path."
},
{
"name": "ALERT_REPORT_WEBHOOK",

View File

@@ -519,6 +519,80 @@ For a connection to a SQL endpoint you need to use the HTTP path from the endpoi
{"connect_args": {"http_path": "/sql/1.0/endpoints/****", "driver_path": "/path/to/odbc/driver"}}
```
##### OAuth2 Authentication
Superset supports OAuth2 authentication for Databricks, allowing users to authenticate with their personal Databricks accounts instead of using shared access tokens. This provides better security and audit capabilities.
###### Prerequisites
1. Create an OAuth2 application in your Databricks account:
- Go to your Databricks account console
- Navigate to **Settings** → **Developer** → **OAuth apps**
- Create a new OAuth app with the redirect URI: `http://your-superset-host:port/api/v1/database/oauth2/`
2. Configure OAuth2 in your `superset_config.py`:
```python
from datetime import timedelta
# OAuth2 configuration for Databricks
# The authorization endpoint is derived from your Databricks workspace host; the
# token endpoint must be set explicitly (see notes below).
DATABASE_OAUTH2_CLIENTS = {
"Databricks (legacy)": {
"id": "your-databricks-client-id",
"secret": "your-databricks-client-secret",
"scope": "sql",
"token_request_uri": "https://your-workspace-host/oidc/v1/token",
},
"Databricks": {
"id": "your-databricks-client-id",
"secret": "your-databricks-client-secret",
"scope": "sql",
"token_request_uri": "https://your-workspace-host/oidc/v1/token",
},
}
# OAuth2 redirect URI (adjust hostname/port for your setup)
DATABASE_OAUTH2_REDIRECT_URI = "http://your-superset-host:port/api/v1/database/oauth2/"
# Optional: OAuth2 timeout
DATABASE_OAUTH2_TIMEOUT = timedelta(seconds=30)
```
Replace the following placeholders:
- `your-databricks-client-id`: Your Databricks OAuth2 application client ID
- `your-databricks-client-secret`: Your Databricks OAuth2 application client secret
- `your-superset-host:port`: Your Superset instance hostname and port
**Multi-Cloud Provider Support**
Databricks fronts the user-to-machine (U2M) OAuth2 flow on every workspace at
`https://<workspace-host>/oidc/v1/authorize` and
`https://<workspace-host>/oidc/v1/token`, regardless of whether the workspace
runs on AWS, Azure, or GCP. Superset derives the **authorization** endpoint
directly from your connection's host, so no cloud provider or account/tenant
identifier needs to be configured.
The **token** endpoint cannot be auto-derived (token exchange has no database
context to read the host), so you must supply `token_request_uri` in
`DATABASE_OAUTH2_CLIENTS`, set to `https://<workspace-host>/oidc/v1/token` for
your workspace.
If you supply a fully-resolved `authorization_request_uri` (and/or
`token_request_uri`), those values take precedence over the host-derived
defaults.
###### Usage
Once configured, users can:
1. Connect to Databricks databases normally using access tokens
2. When querying data, Superset will automatically redirect users to authenticate with Databricks if needed
3. User-specific OAuth2 tokens will be used for database connections, providing better security and audit trails
This feature works with both "Databricks (legacy)" and "Databricks" engine types and automatically supports all major cloud providers (AWS, Azure, GCP).
#### Denodo
The recommended connector library for Denodo is

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.18.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.19.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.18.0](https://img.shields.io/badge/Version-0.18.0-informational?style=flat-square)
![Version: 0.19.0](https://img.shields.io/badge/Version-0.19.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
@@ -46,6 +46,21 @@ It should be a long random bytes or str.
On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverrides.secrets`
## Upgrade Notes
### Kubernetes recommended labels (breaking)
This chart labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. A Deployment's `spec.selector.matchLabels` is immutable, so `helm upgrade` against a release created before this change fails with a `field is immutable` error.
To upgrade an existing release, delete the affected workloads first (their selector labels changed), then upgrade so they are recreated:
```console
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
helm upgrade <release-name> superset/superset
```
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
## Requirements
| Repository | Name | Version |

View File

@@ -45,6 +45,21 @@ It should be a long random bytes or str.
On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverrides.secrets`
## Upgrade Notes
### Kubernetes recommended labels (breaking)
This chart labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. A Deployment's `spec.selector.matchLabels` is immutable, so `helm upgrade` against a release created before this change fails with a `field is immutable` error.
To upgrade an existing release, delete the affected workloads first (their selector labels changed), then upgrade so they are recreated:
```console
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
helm upgrade <release-name> superset/superset
```
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
{{ template "chart.requirementsSection" . }}
{{ template "chart.valuesSection" . }}

View File

@@ -61,6 +61,49 @@ Create chart name and version as used by the chart label.
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Common labels for all resources - follows Kubernetes recommended labels
https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
*/}}
{{- define "superset.labels" -}}
helm.sh/chart: {{ include "superset.chart" . }}
{{ include "superset.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: superset
{{- if .Values.extraLabels }}
{{ toYaml .Values.extraLabels }}
{{- end }}
{{- end -}}
{{/*
Selector labels - used by selectors and matchLabels
*/}}
{{- define "superset.selectorLabels" -}}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{/*
Component labels - extends superset.labels with component-specific labels
Usage: {{ include "superset.componentLabels" (dict "component" "web" "root" .) }}
*/}}
{{- define "superset.componentLabels" -}}
{{ include "superset.labels" .root }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}
{{/*
Component selector labels - for matchLabels with component
Usage: {{ include "superset.componentSelectorLabels" (dict "component" "web" "root" .) }}
*/}}
{{- define "superset.componentSelectorLabels" -}}
{{ include "superset.selectorLabels" .root }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}
{{- define "superset-config" }}
import os
@@ -146,27 +189,32 @@ RESULTS_BACKEND = RedisCache(
{{- end }}
{{- define "supersetNode.selectorLabels" -}}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: web
{{- end }}
{{- define "supersetCeleryBeat.selectorLabels" -}}
app: {{ include "superset.name" . }}-celerybeat
release: {{ .Release.Name }}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: celerybeat
{{- end }}
{{- define "supersetCeleryFlower.selectorLabels" -}}
app: {{ include "superset.name" . }}-flower
release: {{ .Release.Name }}
{{- end }}
{{- define "supersetNode.selectorLabels" -}}
app: {{ include "superset.name" . }}
release: {{ .Release.Name }}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: flower
{{- end }}
{{- define "supersetWebsockets.selectorLabels" -}}
app: {{ include "superset.name" . }}-ws
release: {{ .Release.Name }}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: websocket
{{- end }}
{{- define "supersetWorker.selectorLabels" -}}
app: {{ include "superset.name" . }}-worker
release: {{ .Release.Name }}
app.kubernetes.io/name: {{ include "superset.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: {{ template "superset.fullname" . }}-extra-config
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
data:
{{- range $path, $config := .Values.extraConfigs }}
{{ $path }}: |

View File

@@ -24,13 +24,7 @@ metadata:
name: {{ template "superset.fullname" . }}-celerybeat
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}-celerybeat
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "celerybeat" "root" .) | nindent 4 }}
{{- if .Values.supersetCeleryBeat.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetCeleryBeat.deploymentAnnotations | nindent 4 }}
{{- end }}
@@ -59,11 +53,7 @@ spec:
{{- toYaml .Values.supersetCeleryBeat.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: "{{ template "superset.name" . }}-celerybeat"
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetCeleryBeat.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetCeleryBeat.podLabels }}
{{- toYaml .Values.supersetCeleryBeat.podLabels | nindent 8 }}
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: {{ template "superset.fullname" . }}-flower
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}-flower
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "flower" "root" .) | nindent 4 }}
{{- if .Values.supersetCeleryFlower.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetCeleryFlower.deploymentAnnotations | nindent 4 }}
{{- end }}
@@ -48,11 +42,7 @@ spec:
{{- toYaml .Values.supersetCeleryFlower.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: "{{ template "superset.name" . }}-flower"
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetCeleryFlower.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetCeleryFlower.podLabels }}
{{- toYaml .Values.supersetCeleryFlower.podLabels | nindent 8 }}
{{- end }}

View File

@@ -23,15 +23,9 @@ metadata:
name: {{ template "superset.fullname" . }}-worker
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}-worker
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- if .Values.supersetWorker.deploymentLabels }}
{{- toYaml .Values.supersetWorker.deploymentLabels | nindent 4 }}
{{- include "superset.componentLabels" (dict "component" "worker" "root" .) | nindent 4 }}
{{- with .Values.supersetWorker.deploymentLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if .Values.supersetWorker.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetWorker.deploymentAnnotations | nindent 4 }}
@@ -65,11 +59,7 @@ spec:
{{- toYaml .Values.supersetWorker.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: {{ template "superset.name" . }}-worker
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetWorker.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetWorker.podLabels }}
{{- toYaml .Values.supersetWorker.podLabels | nindent 8 }}
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: "{{ template "superset.fullname" . }}-ws"
namespace: {{ .Release.Namespace }}
labels:
app: "{{ template "superset.name" . }}-ws"
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "websocket" "root" .) | nindent 4 }}
{{- if .Values.supersetWebsockets.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetWebsockets.deploymentAnnotations | nindent 4 }}
{{- end }}
@@ -51,11 +45,7 @@ spec:
{{- toYaml .Values.supersetWebsockets.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: "{{ template "superset.name" . }}-ws"
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetWebsockets.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetWebsockets.podLabels }}
{{- toYaml .Values.supersetWebsockets.podLabels | nindent 8 }}
{{- end }}

View File

@@ -23,15 +23,9 @@ metadata:
name: {{ template "superset.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- if .Values.supersetNode.deploymentLabels }}
{{- toYaml .Values.supersetNode.deploymentLabels | nindent 4 }}
{{- include "superset.componentLabels" (dict "component" "web" "root" .) | nindent 4 }}
{{- with .Values.supersetNode.deploymentLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if .Values.supersetNode.deploymentAnnotations }}
annotations: {{- toYaml .Values.supersetNode.deploymentAnnotations | nindent 4 }}
@@ -67,11 +61,7 @@ spec:
{{- toYaml .Values.supersetNode.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: {{ template "superset.name" . }}
release: {{ .Release.Name }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- include "supersetNode.selectorLabels" . | nindent 8 }}
{{- if .Values.supersetNode.podLabels }}
{{- toYaml .Values.supersetNode.podLabels | nindent 8 }}
{{- end }}

View File

@@ -23,13 +23,7 @@ kind: HorizontalPodAutoscaler
metadata:
name: {{ include "superset.fullname" . }}-hpa
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1

View File

@@ -23,13 +23,7 @@ kind: HorizontalPodAutoscaler
metadata:
name: {{ include "superset.fullname" . }}-hpa-worker
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1

View File

@@ -25,13 +25,7 @@ metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "ingress" "root" .) | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: {{ template "superset.fullname" . }}-init-db
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "init" "root" .) | nindent 4 }}
{{- if .Values.init.jobAnnotations }}
annotations: {{- toYaml .Values.init.jobAnnotations | nindent 4 }}
{{- end }}
@@ -42,9 +36,7 @@ spec:
annotations: {{- toYaml .Values.init.podAnnotations | nindent 8 }}
{{- end }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
{{- include "superset.componentSelectorLabels" (dict "component" "init" "root" .) | nindent 8 }}
job: {{ template "superset.fullname" . }}-init-db
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-celerybeat-pdb
labels:
app: {{ template "superset.name" $ }}-celerybeat
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "celerybeat" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-flower-pdb
labels:
app: {{ template "superset.name" $ }}-flower
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "flower" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-worker-pdb
labels:
app: {{ template "superset.name" $ }}-worker
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "worker" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-ws-pdb
labels:
app: {{ template "superset.name" $ }}-ws
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "websocket" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
metadata:
name: {{ include "superset.fullname" $ }}-pdb
labels:
app: {{ template "superset.name" $ }}
chart: {{ template "superset.chart" $ }}
release: {{ $.Release.Name }}
heritage: {{ $.Release.Service }}
{{- if $.Values.extraLabels }}
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "web" "root" $) | nindent 4 }}
spec:
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}

View File

@@ -23,13 +23,7 @@ metadata:
name: {{ template "superset.fullname" . }}-env
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.fullname" . }}
chart: {{ template "superset.chart" . }}
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
type: Opaque
stringData:
REDIS_HOST: {{ tpl .Values.supersetNode.connections.redis_host . | quote }}

View File

@@ -23,13 +23,7 @@ metadata:
name: {{ template "superset.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.fullname" . }}
chart: {{ template "superset.chart" . }}
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
type: Opaque
stringData:
superset_config.py: |

View File

@@ -24,13 +24,7 @@ metadata:
name: "{{ template "superset.fullname" . }}-ws-config"
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.fullname" . }}
chart: {{ template "superset.chart" . }}
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.labels" . | nindent 4 }}
type: Opaque
stringData:
config.json: |

View File

@@ -24,13 +24,7 @@ metadata:
name: "{{ template "superset.fullname" . }}-flower"
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "flower" "root" .) | nindent 4 }}
{{- with .Values.supersetCeleryFlower.service.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
@@ -45,8 +39,7 @@ spec:
nodePort: {{ .Values.supersetCeleryFlower.service.nodePort.http }}
{{- end }}
selector:
app: {{ template "superset.name" . }}-flower
release: {{ .Release.Name }}
{{- include "supersetCeleryFlower.selectorLabels" . | nindent 4 }}
{{- if .Values.supersetCeleryFlower.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.supersetCeleryFlower.service.loadBalancerIP }}
{{- end }}

View File

@@ -24,13 +24,7 @@ metadata:
name: "{{ template "superset.fullname" . }}-ws"
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "websocket" "root" .) | nindent 4 }}
{{- with .Values.supersetWebsockets.service.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
@@ -45,8 +39,7 @@ spec:
nodePort: {{ .Values.supersetWebsockets.service.nodePort.http }}
{{- end }}
selector:
app: "{{ template "superset.name" . }}-ws"
release: {{ .Release.Name }}
{{- include "supersetWebsockets.selectorLabels" . | nindent 4 }}
{{- if .Values.supersetWebsockets.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.supersetWebsockets.service.loadBalancerIP }}
{{- end }}

View File

@@ -23,13 +23,7 @@ metadata:
name: {{ template "superset.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- include "superset.componentLabels" (dict "component" "web" "root" .) | nindent 4 }}
{{- with .Values.service.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
@@ -44,8 +38,7 @@ spec:
nodePort: {{ .Values.service.nodePort.http }}
{{- end }}
selector:
app: {{ template "superset.name" . }}
release: {{ .Release.Name }}
{{- include "supersetNode.selectorLabels" . | nindent 4 }}
{{- if .Values.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
{{- end }}

View File

@@ -24,17 +24,11 @@ metadata:
name: {{ include "superset.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: {{ include "superset.name" . }}
helm.sh/chart: {{ include "superset.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- include "superset.labels" . | nindent 4 }}
{{- if semverCompare "> 1.6" .Capabilities.KubeVersion.GitVersion }}
kubernetes.io/cluster-service: "true"
{{- end }}
addonmanager.kubernetes.io/mode: Reconcile
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- if .Values.serviceAccount.annotations }}
annotations: {{- toYaml .Values.serviceAccount.annotations | nindent 4 }}
{{- end }}

View File

@@ -0,0 +1,520 @@
#
# 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.
#
suite: Label Consistency Tests
templates:
- deployment.yaml
- deployment-worker.yaml
- deployment-beat.yaml
- deployment-flower.yaml
- deployment-ws.yaml
- service.yaml
- service-ws.yaml
- service-flower.yaml
- init-job.yaml
- ingress.yaml
- configmap-superset.yaml
- secret-superset-config.yaml
- secret-ws.yaml
- pdb.yaml
- pdb-worker.yaml
- pdb-beat.yaml
- pdb-flower.yaml
- pdb-ws.yaml
# These tests validate that Kubernetes recommended labels are consistently applied
# across all chart resources per https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
#
# Required Labels (app.kubernetes.io/):
# - name: The name of the application
# - instance: A unique name identifying the instance of an application
# - version: The current version of the application
# - component: The component within the architecture
# - part-of: The name of a higher level application this one is part of
# - managed-by: The tool being used to manage the operation of an application
#
# Helm-specific Labels:
# - helm.sh/chart: The chart name and version
tests:
# =============================================================================
# Main Deployment Labels
# =============================================================================
- it: should have all recommended labels on main deployment
template: deployment.yaml
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/version"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/managed-by"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/part-of"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- isNotNull:
path: metadata.labels["helm.sh/chart"]
- it: should have correct component label on main deployment
template: deployment.yaml
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: web
- it: should have part-of label set to superset on main deployment
template: deployment.yaml
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/part-of"]
value: superset
# =============================================================================
# Worker Deployment Labels
# =============================================================================
- it: should have all recommended labels on worker deployment
template: deployment-worker.yaml
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/version"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/managed-by"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/part-of"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on worker deployment
template: deployment-worker.yaml
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: worker
# =============================================================================
# Celery Beat Deployment Labels
# =============================================================================
- it: should have all recommended labels on celerybeat deployment
template: deployment-beat.yaml
set:
supersetCeleryBeat.enabled: true
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on celerybeat deployment
template: deployment-beat.yaml
set:
supersetCeleryBeat.enabled: true
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: celerybeat
# =============================================================================
# Flower Deployment Labels
# =============================================================================
- it: should have all recommended labels on flower deployment
template: deployment-flower.yaml
set:
supersetCeleryFlower.enabled: true
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on flower deployment
template: deployment-flower.yaml
set:
supersetCeleryFlower.enabled: true
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: flower
# =============================================================================
# WebSocket Deployment Labels
# =============================================================================
- it: should have all recommended labels on websocket deployment
template: deployment-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on websocket deployment
template: deployment-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: websocket
# =============================================================================
# Service Labels
# =============================================================================
- it: should have all recommended labels on main service
template: service.yaml
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on main service
template: service.yaml
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: web
- it: should have all recommended labels on websocket service
template: service-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on websocket service
template: service-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: websocket
- it: should have all recommended labels on flower service
template: service-flower.yaml
set:
supersetCeleryFlower.enabled: true
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on flower service
template: service-flower.yaml
set:
supersetCeleryFlower.enabled: true
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: flower
# =============================================================================
# Init Job Labels
# =============================================================================
- it: should have all recommended labels on init job
template: init-job.yaml
set:
init.enabled: true
init.createAdmin: true
init.adminUser.password: "test-password"
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on init job
template: init-job.yaml
set:
init.enabled: true
init.createAdmin: true
init.adminUser.password: "test-password"
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: init
# =============================================================================
# Ingress Labels
# =============================================================================
- it: should have all recommended labels on ingress
template: ingress.yaml
set:
ingress.enabled: true
ingress.hosts:
- host: superset.example.com
paths:
- path: /
pathType: Prefix
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/instance"]
- isNotNull:
path: metadata.labels["app.kubernetes.io/component"]
- it: should have correct component label on ingress
template: ingress.yaml
set:
ingress.enabled: true
ingress.hosts:
- host: superset.example.com
paths:
- path: /
pathType: Prefix
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: ingress
# =============================================================================
# Selector Label Consistency
#
# These use value assertions (not isNotNull) on purpose: a missing/misscoped
# release name renders as the string "<no value>", which is non-null and would
# silently pass isNotNull. Asserting the concrete value catches that class of
# bug, and asserting the pod template labels equal the selector guards the
# immutable spec.selector.matchLabels <-> pod label invariant.
# =============================================================================
- it: should set selector matchLabels to concrete values on main deployment
template: deployment.yaml
asserts:
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/name"]
value: superset
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: web
- it: should match pod template labels to the selector on main deployment
template: deployment.yaml
asserts:
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/name"]
value: superset
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/component"]
value: web
- it: should set selector matchLabels to concrete values on worker deployment
template: deployment-worker.yaml
asserts:
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: worker
- it: should match pod template labels to the selector on worker deployment
template: deployment-worker.yaml
asserts:
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/component"]
value: worker
# =============================================================================
# Extra Labels Support
# =============================================================================
- it: should include extraLabels when specified
template: deployment.yaml
set:
extraLabels:
custom-label: custom-value
environment: production
asserts:
- equal:
path: metadata.labels.custom-label
value: custom-value
- equal:
path: metadata.labels.environment
value: production
- it: should include extraLabels in service
template: service.yaml
set:
extraLabels:
custom-label: custom-value
asserts:
- equal:
path: metadata.labels.custom-label
value: custom-value
# =============================================================================
# ConfigMap / Secret Labels
# =============================================================================
- it: should have recommended labels on extra-config configmap
template: configmap-superset.yaml
set:
extraConfigs:
custom.py: "FOO = 1"
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- equal:
path: metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- isNotNull:
path: metadata.labels["app.kubernetes.io/managed-by"]
- equal:
path: metadata.labels["app.kubernetes.io/part-of"]
value: superset
- it: should have recommended labels on superset config secret
template: secret-superset-config.yaml
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- equal:
path: metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: metadata.labels["app.kubernetes.io/part-of"]
value: superset
- it: should have recommended labels on websocket config secret
template: secret-ws.yaml
set:
supersetWebsockets.enabled: true
asserts:
- isNotNull:
path: metadata.labels["app.kubernetes.io/name"]
- equal:
path: metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
# =============================================================================
# PodDisruptionBudget Labels (metadata must match the selector)
# =============================================================================
- it: should have recommended labels and matching selector on main pdb
template: pdb.yaml
set:
supersetNode.podDisruptionBudget.enabled: true
supersetNode.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: web
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: web
- it: should set correct component on worker pdb
template: pdb-worker.yaml
set:
supersetWorker.podDisruptionBudget.enabled: true
supersetWorker.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: worker
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: worker
- it: should set correct component on celerybeat pdb
template: pdb-beat.yaml
set:
supersetCeleryBeat.podDisruptionBudget.enabled: true
supersetCeleryBeat.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: celerybeat
- it: should set correct component on flower pdb
template: pdb-flower.yaml
set:
supersetCeleryFlower.podDisruptionBudget.enabled: true
supersetCeleryFlower.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: flower
- it: should set correct component on websocket pdb
template: pdb-ws.yaml
set:
supersetWebsockets.enabled: true
supersetWebsockets.podDisruptionBudget.enabled: true
supersetWebsockets.podDisruptionBudget.maxUnavailable: null
asserts:
- equal:
path: metadata.labels["app.kubernetes.io/component"]
value: websocket
- equal:
path: spec.selector.matchLabels["app.kubernetes.io/component"]
value: websocket
- it: should use recommended labels on init job pod template
template: init-job.yaml
set:
init.enabled: true
asserts:
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/component"]
value: init
- isNotNull:
path: spec.template.metadata.labels.job

View File

@@ -54,7 +54,7 @@ dependencies = [
"cryptography>=48.0.0, <49.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.1, <6.0.0",
"flask-appbuilder>=5.2.2, <6.0.0",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -64,7 +64,7 @@ dependencies = [
"flask-wtf>=1.3.0, <2.0",
"geopy",
"greenlet<=3.5.1, >=3.5.1",
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
"holidays>=0.45, <1",
@@ -101,7 +101,7 @@ dependencies = [
"pyyaml>=6.0.3, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=5.0.0, <6.0",
"rison>=2.0.0, <3.0",
"rison>=2.0.1, <3.0",
"selenium>=4.45.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
"sshtunnel>=0.4.0, <0.5",
@@ -174,11 +174,11 @@ hive = [
]
impala = ["impyla>0.16.2, <0.23"]
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
kylin = ["kylinpy>=2.8.1, <2.9"]
kylin = ["kylinpy>=2.8.4, <2.9"]
mssql = ["pymssql>=2.3.13, <3"]
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.1.0, <3"]
mysql = ["mysqlclient>=2.2.8, <3"]
ocient = [
"sqlalchemy-ocient>=1.0.0",
"pyocient>=1.0.15, <4",
@@ -216,7 +216,7 @@ netezza = ["nzalchemy>=11.0.2, < 11.2"]
starrocks = ["starrocks>=1.3.3, <2"]
doris = ["pydoris>=1.0.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1.2"]
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.5"]
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
development = [
# no bounds for apache-superset-extensions-cli until a stable version
"apache-superset-extensions-cli",

View File

@@ -122,7 +122,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.2.1
flask-appbuilder==5.2.2
# via
# apache-superset (pyproject.toml)
# apache-superset-core
@@ -169,7 +169,7 @@ greenlet==3.5.1
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==25.3.0
gunicorn==26.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
# via wsproto
@@ -369,7 +369,7 @@ rfc3339-validator==0.1.4
# via openapi-schema-validator
rich==13.9.4
# via flask-limiter
rison==2.0.0
rison==2.0.1
# via apache-superset (pyproject.toml)
rpds-py==0.25.0
# via

View File

@@ -263,7 +263,7 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==5.2.1
flask-appbuilder==5.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -393,7 +393,7 @@ grpcio==1.81.1
# grpcio-status
grpcio-status==1.60.1
# via google-api-core
gunicorn==25.3.0
gunicorn==26.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -572,7 +572,7 @@ msgspec==0.19.0
# via
# -c requirements/base-constraint.txt
# flask-session
mysqlclient==2.2.6
mysqlclient==2.2.8
# via apache-superset
nh3==0.3.5
# via
@@ -913,7 +913,7 @@ rich==13.9.4
# rich-rst
rich-rst==1.3.1
# via cyclopts
rison==2.0.0
rison==2.0.1
# via
# -c requirements/base-constraint.txt
# apache-superset

File diff suppressed because it is too large Load Diff

View File

@@ -192,18 +192,19 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.25.0",
"markdown-to-jsx": "^9.8.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
"mustache": "^4.2.0",
"nanoid": "^5.1.15",
"nanoid": "^5.1.16",
"ol": "^10.9.0",
"query-string": "9.4.0",
"query-string": "9.4.1",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
"react-arborist": "^3.10.5",
"react-arborist": "^3.12.0",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
@@ -239,8 +240,7 @@
"use-query-params": "^2.2.2",
"uuid": "^14.0.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "^18.0.0",
"lodash-es": "^4.17.21"
"yargs": "^18.0.0"
},
"devDependencies": {
"@babel/cli": "^7.29.7",
@@ -265,9 +265,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.4.5",
"@storybook/addon-links": "10.4.4",
"@storybook/react-webpack5": "10.4.4",
"@storybook/addon-docs": "10.4.6",
"@storybook/addon-links": "10.4.6",
"@storybook/react-webpack5": "10.4.6",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.43",
@@ -283,6 +283,7 @@
"@types/jquery": "^4.0.1",
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.0.1",
"@types/react": "^18.3.0",
@@ -297,20 +298,19 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.61.1",
"@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.61.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.10.38",
"baseline-browser-mapping": "^2.10.40",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"css-minimizer-webpack-plugin": "^8.0.0",
"eslint": "^10.5.0",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
@@ -342,10 +342,11 @@
"lerna": "^9.0.4",
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"open-cli": "^9.0.0",
"oxlint": "^1.71.0",
"po2json": "^0.4.5",
"prettier": "3.8.4",
"prettier": "3.9.3",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^16.0.1",
@@ -358,7 +359,6 @@
"storybook": "10.4.6",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",
"ts-jest": "^29.4.11",
"tscw-config": "^1.1.2",
"tsx": "^4.22.4",
@@ -366,14 +366,13 @@
"unzipper": "^0.12.5",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.10",
"webpack": "^5.107.2",
"webpack": "^5.108.3",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-manifest-plugin": "^6.0.1",
"webpack-sources": "^3.5.0",
"webpack-visualizer-plugin2": "^2.0.0",
"@types/lodash-es": "^4.17.12"
"webpack-visualizer-plugin2": "^2.0.0"
},
"peerDependencies": {
"ace-builds": "^1.41.0",

View File

@@ -122,6 +122,6 @@
"access": "public"
},
"dependencies": {
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
}
}

View File

@@ -237,6 +237,27 @@ export interface SupersetSpecificTokens {
* Fallback: transparent
*/
buttonSecondaryActiveBorderColor?: string;
// Component flexibility tokens (sizing, radius, behavior)
selectOptionActiveOutline?: boolean;
labelBorderRadius?: number;
buttonControlHeight?: number;
buttonControlHeightSM?: number;
buttonControlHeightXS?: number;
buttonPaddingInline?: number;
buttonPaddingInlineSM?: number;
buttonFontSize?: number;
buttonBorderRadius?: number;
buttonStyleMap?: Record<
string,
{ type?: string; variant?: string; color?: string }
>;
// Dashboard tile tokens (opt-in, fallbacks: colorBgContainer bg, no border, borderRadius, hairline box-shadow)
dashboardTileBg?: string;
dashboardTileBorder?: string;
dashboardTileBorderRadius?: number;
dashboardTileBoxShadow?: string;
}
/**

View File

@@ -173,3 +173,82 @@ test('resetTranslation does nothing when not yet configured', () => {
consoleSpy.mockRestore();
});
});
// --- autoConfigureFromWindow ----------------------------------------------
// These cover the bootstrap-injection path used to dodge the
// module-level `const X = t(...)` race across code-split chunks
// (upstream issue #35330).
test('t() self-configures from window.__SUPERSET_LANGUAGE_PACK__ on first call', () => {
jest.isolateModules(() => {
window.__SUPERSET_LANGUAGE_PACK__ = {
domain: 'superset',
locale_data: {
superset: {
'': {
domain: 'superset',
lang: 'fr',
plural_forms: 'nplurals=2; plural=(n > 1);',
},
hello: ['bonjour'],
},
},
};
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { t } = require('./TranslatorSingleton');
expect(t('hello')).toBe('bonjour');
// No "should call configure" warning because we self-configured first.
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
delete window.__SUPERSET_LANGUAGE_PACK__;
});
});
test('t() falls back to msgid when window has no language pack', () => {
jest.isolateModules(() => {
delete window.__SUPERSET_LANGUAGE_PACK__;
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { t } = require('./TranslatorSingleton');
expect(t('hello')).toBe('hello');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/was called before configure\(\)/),
);
consoleSpy.mockRestore();
});
});
test('explicit configure() takes precedence over window pack', () => {
jest.isolateModules(() => {
window.__SUPERSET_LANGUAGE_PACK__ = {
domain: 'superset',
locale_data: {
superset: {
'': {
domain: 'superset',
lang: 'fr',
plural_forms: 'nplurals=2; plural=(n > 1);',
},
hello: ['bonjour'],
},
},
};
const { configure, t } = require('./TranslatorSingleton');
configure({
languagePack: {
domain: 'superset',
locale_data: {
superset: {
'': {
domain: 'superset',
lang: 'es',
plural_forms: 'nplurals=2; plural=(n != 1);',
},
hello: ['hola'],
},
},
},
});
expect(t('hello')).toBe('hola');
delete window.__SUPERSET_LANGUAGE_PACK__;
});
});

View File

@@ -36,7 +36,31 @@ function configure(config?: TranslatorConfig) {
return singleton;
}
// When webpack splits @apache-superset/core across chunks, each
// chunk-local copy of this module has its own `singleton` and
// `isConfigured` state. The first `t()` call in a late chunk hits a
// fresh, unconfigured Translator and returns the English msgid. To
// make translations survive chunk duplication, the HTML template
// stashes the language pack on window and we self-configure from it
// on first access. See upstream issue #35330.
declare global {
interface Window {
__SUPERSET_LANGUAGE_PACK__?: TranslatorConfig['languagePack'];
}
}
function autoConfigureFromWindow() {
if (isConfigured) return;
if (typeof window === 'undefined') return;
const pack = window.__SUPERSET_LANGUAGE_PACK__;
if (pack) {
configure({ languagePack: pack });
}
}
function getInstance() {
autoConfigureFromWindow();
if (typeof singleton === 'undefined') {
singleton = new Translator();
}
@@ -85,11 +109,16 @@ function addLocaleData(data: LocaleData) {
}
function t(input: string, ...args: unknown[]) {
// Self-configure from the bootstrap-injected window pack before deciding
// whether to warn, so a chunk-local copy that hasn't seen configure() yet
// doesn't warn (or fall back to English) when the pack is available.
autoConfigureFromWindow();
if (!isConfigured) warnPreConfigure('t', input);
return getInstance().translate(input, ...args);
}
function tn(key: string, ...args: unknown[]) {
autoConfigureFromWindow();
if (!isConfigured) warnPreConfigure('tn', key);
return getInstance().translateWithNumber(key, ...args);
}

View File

@@ -28,7 +28,7 @@
"@types/react": "*",
"lodash": "^4.18.1",
"tinycolor2": "*",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",

View File

@@ -713,4 +713,5 @@ export interface DataColumnMeta {
isChildColumn?: boolean;
description?: string;
currencyCodeColumn?: string;
isFilterable?: boolean;
}

View File

@@ -67,7 +67,7 @@
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.15",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"devDependencies": {
"@emotion/styled": "^11.14.1",

View File

@@ -125,6 +125,11 @@ InteractiveButton.argTypes = {
options: buttonSizes,
control: { type: 'select' },
},
styleConfig: {
description:
'Optional visual overrides (controlHeight, paddingInline, fontSize, fontWeight, borderRadius, ctaMinWidth, ctaMinHeight, iconGap).',
control: { type: 'object' },
},
target: {
name: TARGETS.label,
control: { type: 'select' },

View File

@@ -186,6 +186,68 @@ test('getSecondaryButtonHoverStyles supports partial token overrides', () => {
expect(hoverStyles['&:active'].backgroundColor).toBe('#99d3df !important');
});
test('styleConfig overrides theme defaults', () => {
const { getByRole } = render(
<Button
buttonStyle="primary"
styleConfig={{
controlHeight: 50,
fontSize: 20,
fontWeight: 900,
paddingInline: 30,
borderRadius: 12,
}}
>
Custom
</Button>,
);
expect(getByRole('button')).toBeInTheDocument();
});
test('styleConfig partial override merges with defaults', () => {
const { getByRole } = render(
<Button buttonStyle="primary" styleConfig={{ controlHeight: 44 }}>
Partial
</Button>,
);
expect(getByRole('button')).toBeInTheDocument();
});
test('xsmall size resolves with theme tokens', () => {
const { getByRole } = render(
<Button buttonSize="xsmall" buttonStyle="primary">
XSmall
</Button>,
);
expect(getByRole('button')).toBeInTheDocument();
expect(getByRole('button')).toHaveClass('superset-button');
});
test('small size resolves with theme tokens', () => {
const { getByRole } = render(
<Button buttonSize="small" buttonStyle="primary">
Small
</Button>,
);
expect(getByRole('button')).toBeInTheDocument();
expect(getByRole('button')).toHaveClass('superset-button');
});
test('primary buttonStyle applies ant-btn-primary class', () => {
const { getByRole } = render(<Button buttonStyle="primary">Primary</Button>);
expect(getByRole('button')).toHaveClass('ant-btn-primary');
});
test('danger buttonStyle applies ant-btn-dangerous class', () => {
const { getByRole } = render(<Button buttonStyle="danger">Danger</Button>);
expect(getByRole('button')).toHaveClass('ant-btn-dangerous');
});
test('link buttonStyle applies ant-btn-link class', () => {
const { getByRole } = render(<Button buttonStyle="link">Link</Button>);
expect(getByRole('button')).toHaveClass('ant-btn-link');
});
test('getSecondaryButtonStyle falls back when tokens are empty strings', () => {
const mockTheme = {
colorPrimary: '#2893B3',

View File

@@ -25,6 +25,7 @@ import type { SupersetTheme } from '@apache-superset/core/theme';
import type {
ButtonColorType,
ButtonProps,
ButtonStyleConfig,
ButtonStyle,
ButtonType,
ButtonVariantType,
@@ -84,14 +85,13 @@ export const getSecondaryButtonHoverStyles = (theme: SupersetTheme) => ({
},
});
const BUTTON_STYLE_MAP: Record<
ButtonStyle,
{
type?: ButtonType;
variant?: ButtonVariantType;
color?: ButtonColorType;
}
> = {
type ButtonStyleMapping = {
type?: ButtonType;
variant?: ButtonVariantType;
color?: ButtonColorType;
};
const BUTTON_STYLE_MAP: Record<ButtonStyle, ButtonStyleMapping> = {
primary: { type: 'primary', variant: 'solid', color: 'primary' },
secondary: { variant: 'filled', color: 'primary' },
tertiary: { variant: 'outlined', color: 'default' },
@@ -113,30 +113,56 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
href,
showMarginRight = true,
icon,
styleConfig,
...restProps
} = props;
const theme = useTheme();
const { fontSizeSM, fontWeightStrong } = theme;
const { fontWeightStrong } = theme;
const btnFontSize = theme.buttonFontSize ?? theme.fontSizeSM;
let height = 32;
let padding = 18;
const resolvedStyleMap: Record<ButtonStyle, ButtonStyleMapping> =
theme.buttonStyleMap
? (Object.fromEntries(
Object.entries(BUTTON_STYLE_MAP).map(([key, value]) => [
key,
{ ...value, ...theme.buttonStyleMap?.[key as ButtonStyle] },
]),
) as Record<ButtonStyle, ButtonStyleMapping>)
: BUTTON_STYLE_MAP;
let defaultHeight = theme.buttonControlHeight ?? 32;
let defaultPaddingInline = theme.buttonPaddingInline ?? 18;
let defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadius;
if (buttonSize === 'xsmall') {
height = 22;
padding = 5;
defaultHeight = theme.buttonControlHeightXS ?? 22;
defaultPaddingInline = 5;
defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadiusSM;
} else if (buttonSize === 'small') {
height = 30;
padding = 10;
defaultHeight = theme.buttonControlHeightSM ?? 30;
defaultPaddingInline = theme.buttonPaddingInlineSM ?? 10;
defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadiusSM;
}
if (buttonStyle === 'link') {
padding = 4;
defaultPaddingInline = 4;
}
const resolvedStyleConfig: Required<ButtonStyleConfig> = {
controlHeight: styleConfig?.controlHeight ?? defaultHeight,
paddingInline: styleConfig?.paddingInline ?? defaultPaddingInline,
fontSize: styleConfig?.fontSize ?? btnFontSize,
fontWeight: styleConfig?.fontWeight ?? fontWeightStrong,
ctaMinWidth: styleConfig?.ctaMinWidth ?? theme.sizeUnit * 36,
ctaMinHeight: styleConfig?.ctaMinHeight ?? theme.sizeUnit * 8,
iconGap: styleConfig?.iconGap ?? theme.sizeUnit * 2,
borderRadius: styleConfig?.borderRadius ?? defaultBorderRadius,
};
const {
type: antdType = 'default',
variant,
color,
} = BUTTON_STYLE_MAP[buttonStyle ?? 'primary'];
} = resolvedStyleMap[buttonStyle ?? 'primary'] ?? BUTTON_STYLE_MAP.primary;
const element = children as ReactElement;
@@ -148,7 +174,9 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
renderedChildren = Children.toArray(children);
}
const firstChildMargin =
showMarginRight && renderedChildren.length > 1 ? theme.sizeUnit * 2 : 0;
showMarginRight && renderedChildren.length > 1
? resolvedStyleConfig.iconGap
: 0;
const effectiveButtonStyle: ButtonStyle = buttonStyle ?? 'primary';
@@ -167,11 +195,10 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
variant={variant}
danger={effectiveButtonStyle === 'danger'}
color={color}
// Static class names for embedded-dashboard CSS targeting
className={cx(
className,
'superset-button',
// A static class name containing the button style is available to
// support customizing button styles in embedded dashboards.
`superset-button-${buttonStyle}`,
{ cta: !!cta },
)}
@@ -180,12 +207,13 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1,
fontSize: fontSizeSM,
fontWeight: fontWeightStrong,
height,
padding: `0px ${padding}px`,
minWidth: cta ? theme.sizeUnit * 36 : undefined,
minHeight: cta ? theme.sizeUnit * 8 : undefined,
fontSize: resolvedStyleConfig.fontSize,
fontWeight: resolvedStyleConfig.fontWeight,
height: resolvedStyleConfig.controlHeight,
padding: `0px ${resolvedStyleConfig.paddingInline}px`,
borderRadius: resolvedStyleConfig.borderRadius,
minWidth: cta ? resolvedStyleConfig.ctaMinWidth : undefined,
minHeight: cta ? resolvedStyleConfig.ctaMinHeight : undefined,
marginLeft: 0,
'& + .superset-button:not(.ant-btn-compact-item)': {
marginLeft: theme.sizeUnit * 2,

View File

@@ -40,6 +40,17 @@ export type ButtonStyle =
export type ButtonSize = 'default' | 'small' | 'xsmall';
export type ButtonStyleConfig = {
controlHeight?: number;
paddingInline?: number;
fontSize?: number;
fontWeight?: number;
ctaMinWidth?: number;
ctaMinHeight?: number;
iconGap?: number;
borderRadius?: number;
};
export type ButtonProps = Omit<AntdButtonProps, 'css'> & {
placement?: TooltipPlacement;
tooltip?: ReactNode;
@@ -49,4 +60,5 @@ export type ButtonProps = Omit<AntdButtonProps, 'css'> & {
cta?: boolean;
showMarginRight?: boolean;
icon?: ReactNode;
styleConfig?: ButtonStyleConfig;
};

View File

@@ -60,6 +60,11 @@ InteractiveDropdownButton.args = {
};
InteractiveDropdownButton.argTypes = {
styleConfig: {
description:
'Optional visual overrides (controlHeight, fontSize, fontWeight, boxShadow).',
control: { type: 'object' },
},
placement: {
defaultValue: 'top',
control: { type: 'select' },

View File

@@ -0,0 +1,76 @@
/**
* 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 { fireEvent, render, waitFor } from '@superset-ui/core/spec';
import { DropdownButton } from '.';
const menuProps = { items: [{ key: '1', label: 'Item 1' }] };
test('renders without crashing when no styleConfig is provided', () => {
const { container } = render(
<DropdownButton menu={menuProps}>Click</DropdownButton>,
);
expect(container.querySelector('.ant-btn')).toBeInTheDocument();
});
test('renders without crashing with full styleConfig', () => {
const { container } = render(
<DropdownButton
menu={menuProps}
styleConfig={{
controlHeight: 40,
fontSize: 16,
fontWeight: 700,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
>
Click
</DropdownButton>,
);
expect(container.querySelector('.ant-btn')).toBeInTheDocument();
});
test('renders tooltip when tooltip prop is provided', async () => {
const { getByText } = render(
<DropdownButton menu={menuProps} tooltip="My Tooltip">
Click
</DropdownButton>,
);
fireEvent.mouseEnter(getByText('Click'));
await waitFor(() => {
expect(document.querySelector('[role="tooltip"]')).toHaveTextContent(
'My Tooltip',
);
});
});
test('does not render tooltip wrapper when tooltip is not provided', () => {
const { container } = render(
<DropdownButton menu={menuProps}>Click</DropdownButton>,
);
expect(container.querySelector('[id$="-tooltip"]')).not.toBeInTheDocument();
});
test('passes button type to underlying Dropdown.Button', () => {
const { container } = render(
<DropdownButton menu={menuProps} type="primary">
Click
</DropdownButton>,
);
expect(container.querySelector('.ant-btn-primary')).toBeInTheDocument();
});

View File

@@ -27,6 +27,7 @@ export const DropdownButton = ({
tooltip,
tooltipPlacement,
children,
styleConfig,
...rest
}: DropdownButtonProps) => {
const theme = useTheme();
@@ -57,10 +58,14 @@ export const DropdownButton = ({
defaultBtnCss,
css`
.ant-btn {
height: 30px;
box-shadow: none;
font-size: ${theme.fontSizeSM}px;
font-weight: ${theme.fontWeightStrong};
height: ${styleConfig?.controlHeight ??
theme.buttonControlHeightSM ??
30}px;
box-shadow: ${styleConfig?.boxShadow ?? 'none'};
font-size: ${styleConfig?.fontSize ??
theme.buttonFontSize ??
theme.fontSizeSM}px;
font-weight: ${styleConfig?.fontWeight ?? theme.fontWeightStrong};
}
`,
]}
@@ -85,4 +90,4 @@ export const DropdownButton = ({
return button;
};
export type { DropdownButtonProps };
export type { DropdownButtonProps, DropdownButtonStyleConfig } from './types';

View File

@@ -21,7 +21,15 @@ import { type ComponentProps } from 'react';
import { Dropdown } from 'antd';
import type { TooltipPlacement } from '../Tooltip/types';
export type DropdownButtonStyleConfig = {
controlHeight?: number;
fontSize?: number;
fontWeight?: number;
boxShadow?: string;
};
export type DropdownButtonProps = ComponentProps<typeof Dropdown.Button> & {
tooltip?: string;
tooltipPlacement?: TooltipPlacement;
styleConfig?: DropdownButtonStyleConfig;
};

View File

@@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({
isValidating ? 'validating' : hasError ? 'error' : 'success'
}
help={errorMessage || helpText}
hasFeedback={!!hasError}
hasFeedback={isValidating || !!hasError}
>
{visibilityToggle || props.name === 'password' ? (
<StyledInputPassword

View File

@@ -31,5 +31,6 @@ export interface LabeledErrorBoundInputProps {
id?: string;
classname?: string;
visibilityToggle?: boolean;
isValidating?: boolean;
[x: string]: any;
}

View File

@@ -53,7 +53,7 @@ export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
overflow: hidden;
text-overflow: ellipsis;
background-color: ${backgroundColor};
border-radius: 8px;
border-radius: ${theme.labelBorderRadius ?? 8}px;
border-color: ${borderColor};
padding: 0.35em 0.8em;
line-height: 1;

View File

@@ -32,10 +32,11 @@ import { CertifiedBadge } from '../CertifiedBadge';
import { Button } from '../Button';
export const menuTriggerStyles = (theme: SupersetTheme) => css`
width: ${theme.sizeUnit * 8}px;
height: ${theme.sizeUnit * 8}px;
width: ${theme.buttonControlHeight ?? theme.sizeUnit * 8}px;
height: ${theme.buttonControlHeight ?? theme.sizeUnit * 8}px;
padding: 0;
border: 1px solid ${theme.colorPrimary};
border-radius: ${theme.buttonBorderRadius ?? theme.borderRadius}px;
&.ant-btn > span.anticon {
line-height: 0;

View File

@@ -398,6 +398,25 @@ test('removes duplicated values', async () => {
});
});
test('trims whitespace from pasted comma-separated values', async () => {
render(<AsyncSelect {...defaultProps} mode="multiple" allowNewOptions />);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'a, b, c , d',
},
});
fireEvent(input, paste);
await waitFor(async () => {
const values = await findAllSelectValues();
expect(values.length).toBe(4);
expect(values[0]).toHaveTextContent('a');
expect(values[1]).toHaveTextContent('b');
expect(values[2]).toHaveTextContent('c');
expect(values[3]).toHaveTextContent('d');
});
});
test('renders a custom label', async () => {
const loadOptions = jest.fn(async () => ({
data: [

View File

@@ -694,7 +694,14 @@ const AsyncSelect = forwardRef(
}
} else {
const token = tokenSeparators.find(token => pastedText.includes(token));
const array = token ? uniq(pastedText.split(token)) : [pastedText];
const array = token
? uniq(
pastedText
.split(token)
.map(s => s.trim())
.filter(Boolean),
)
: [pastedText.trim()].filter(Boolean);
const values = (
await Promise.all(array.map(item => getPastedTextValue(item)))
).filter(item => item !== undefined) as AntdLabeledValue[];

View File

@@ -378,6 +378,23 @@ test('removes duplicated values', async () => {
expect(values[3]).toHaveTextContent('d');
});
test('trims whitespace from pasted comma-separated values', async () => {
render(<Select {...defaultProps} mode="multiple" allowNewOptions />);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'a, b, c , d',
},
});
fireEvent(input, paste);
const values = await findAllSelectValues();
expect(values.length).toBe(4);
expect(values[0]).toHaveTextContent('a');
expect(values[1]).toHaveTextContent('b');
expect(values[2]).toHaveTextContent('c');
expect(values[3]).toHaveTextContent('d');
});
test('renders a custom label', async () => {
const options = [
{ value: 'John', label: <h1>John</h1> },

View File

@@ -45,10 +45,11 @@ export const StyledContainer = styled.div<{ headerPosition: string }>`
export const StyledSelect = styled(Select, {
shouldForwardProp: prop => prop !== 'headerPosition' && prop !== 'oneLine',
})<{ headerPosition?: string; oneLine?: boolean }>`
${({ theme, headerPosition, oneLine }) => `
${({ theme, headerPosition, oneLine }) => {
const useSubtleOptionHover = theme.selectOptionActiveOutline === false;
return `
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
outline: 2px solid ${theme.colorPrimary};
outline-offset: -2px;
${useSubtleOptionHover ? 'outline: none;' : `outline: 2px solid ${theme.colorPrimary}; outline-offset: -2px;`}
}
flex: ${headerPosition === 'left' ? 1 : 0};
line-height: ${theme.sizeXL}px;
@@ -82,7 +83,8 @@ export const StyledSelect = styled(Select, {
}
`
};
`}
`;
}}
`;
export const NoElement = styled.span`

View File

@@ -19,6 +19,7 @@
import { Page, Locator } from '@playwright/test';
import { TIMEOUT } from '../utils/constants';
import { AgGrid } from '../components/core/AgGrid';
/**
* Explore Page object
@@ -30,6 +31,11 @@ export class ExplorePage {
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
CHART_CONTAINER: '[data-test="chart-container"]',
// The bottom data panel (DataTablesPane / SouthPane) with Results/Samples tabs
SOUTH_PANE: '[data-test="some-purposeful-instance"]',
EXPAND_DATA_PANEL: '[aria-label="Expand data panel"]',
RESULTS_TAB: '[data-node-key="results"]',
ACTIVE_TABPANE: '.ant-tabs-tabpane-active',
} as const;
constructor(page: Page) {
@@ -106,4 +112,44 @@ export class ExplorePage {
getVizSwitcher(): Locator {
return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
}
/**
* Expands the bottom data panel if it is currently collapsed.
* Safe to call when already expanded (no-op).
*/
async expandDataPanel(): Promise<void> {
const expandButton = this.page.locator(
ExplorePage.SELECTORS.EXPAND_DATA_PANEL,
);
if (await expandButton.isVisible().catch(() => false)) {
await expandButton.click();
}
}
/**
* Opens the bottom data panel and activates the "Results" tab. Clicking the
* already-active tab collapses the panel, so the click is guarded.
*/
async openResultsTab(): Promise<void> {
await this.expandDataPanel();
const resultsTab = this.page
.locator(ExplorePage.SELECTORS.SOUTH_PANE)
.locator(ExplorePage.SELECTORS.RESULTS_TAB);
const className = (await resultsTab.getAttribute('class')) ?? '';
if (!className.includes('ant-tabs-tab-active')) {
await resultsTab.click();
}
}
/**
* Returns an AgGrid wrapper around the currently active Results tab grid.
*/
getResultsGrid(): AgGrid {
const grid = this.page
.locator(ExplorePage.SELECTORS.SOUTH_PANE)
.locator(ExplorePage.SELECTORS.ACTIVE_TABPANE)
.locator('[role="grid"]')
.first();
return new AgGrid(this.page, grid);
}
}

View File

@@ -29,6 +29,7 @@ import { waitForPost } from '../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../helpers/api/assertions';
import { getDatabaseByName } from '../../helpers/api/database';
import { apiExecuteSql } from '../../helpers/api/sqllab';
import { TIMEOUT } from '../../utils/constants';
interface ExamplesSetupResult {
tableName: string;
@@ -116,7 +117,7 @@ async function dropTempTable(
// Uses test.describe only because Playwright's serial mode API requires it -
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('create dataset wizard', () => {
test.describe.configure({ mode: 'serial' });
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
test('should create a dataset via wizard', async ({ page, testAssets }) => {
const { tableName, dbId, createDatasetPage } = await setupExamplesDataset(

View File

@@ -0,0 +1,123 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Regression coverage for #38152 (PR #38165): the Explore "Results" tab reuses
* the chart's already-fetched query data instead of firing a second, identical
* request to /api/v1/chart/data. The duplicate only exists across the real
* backend round-trip; the reuse-vs-fallback and row-limit slicing logic is
* unit-tested in useResultsPane.test.tsx, so this E2E asserts only the
* end-to-end win: opening Results issues no extra chart/data request.
*
* Lives under tests/experimental/ until proven stable in CI; run with:
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
* tests/experimental/explore/results-tab-reuse.spec.ts -- --headed
*/
import { testWithAssets, expect } from '../../../helpers/fixtures';
import { apiPostChart } from '../../../helpers/api/chart';
import { getDatasetByName } from '../../../helpers/api/dataset';
import { ExplorePage } from '../../../pages/ExplorePage';
import { TIMEOUT } from '../../../utils/constants';
const DATASET_NAME = 'birth_names';
const CHART_DATA_PATH = '/api/v1/chart/data';
testWithAssets(
'Results tab reuses chart data without a duplicate query (#38165)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
let chartDataRequests = 0;
page.on('request', request => {
if (
request.method() === 'POST' &&
request.url().includes(CHART_DATA_PATH)
) {
chartDataRequests += 1;
}
});
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const datasetId = dataset.id;
const params = {
datasource: `${datasetId}__table`,
viz_type: 'table',
query_mode: 'raw',
all_columns: ['name', 'gender', 'num'],
adhoc_filters: [],
order_by_cols: [],
row_limit: 1000,
server_pagination: false,
};
const chartResp = await apiPostChart(page, {
slice_name: `results_tab_reuse_${Date.now()}`,
viz_type: 'table',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(params),
});
expect(chartResp.ok()).toBe(true);
const chartBody = await chartResp.json();
const chartId: number = chartBody.result?.id ?? chartBody.id;
expect(chartId, 'chart creation should return an id').toBeTruthy();
testAssets.trackChart(chartId);
// Wait for the chart query to finish so its result is in Redux; opening
// Results before that races and falls back to its own request.
const explorePage = new ExplorePage(page);
const chartQueryFinished = page.waitForResponse(
response =>
response.request().method() === 'POST' &&
response.url().includes(CHART_DATA_PATH),
{ timeout: TIMEOUT.API_RESPONSE },
);
await explorePage.goto(chartId);
await chartQueryFinished;
const chartContainer = explorePage.getChartContainer();
await chartContainer.waitFor({
state: 'visible',
timeout: TIMEOUT.PAGE_LOAD,
});
await chartContainer
.getByRole('status', { name: 'Loading' })
.waitFor({ state: 'hidden', timeout: TIMEOUT.API_RESPONSE })
.catch(() => {}); // spinner may have already cleared
expect(chartDataRequests).toBeGreaterThanOrEqual(1);
const requestsAfterChartLoad = chartDataRequests;
await explorePage.openResultsTab();
const grid = explorePage.getResultsGrid();
await grid.waitForRows({ timeout: TIMEOUT.API_RESPONSE });
expect(await grid.getRowCount()).toBeGreaterThan(0);
// Allow time for an unwanted duplicate request, then assert none fired.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1500);
expect(
chartDataRequests,
'opening Results must not trigger a second chart/data query',
).toBe(requestsAfterChartLoad);
},
);

View File

@@ -37,7 +37,7 @@
"dompurify": "^3.4.11",
"prop-types": "^15.8.1",
"urijs": "^1.19.11",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@apache-superset/core": "*",

View File

@@ -33,7 +33,7 @@
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",

View File

@@ -86,6 +86,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
onChartStateChange,
chartState,
metricSqlExpressions,
rawSummaryColumns,
showNumberedColumn,
} = props;
@@ -113,26 +114,58 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
}, [columns]);
// A single effect owns every ownState write derived from render state.
// updateTableOwnState replaces ownState wholesale, so separate effects that
// each spread serverPaginationData in the same render would clobber one
// another's keys: clamping the current page, priming the raw-mode summary
// columns and nudging a re-query for missing totals must be one combined
// delta.
useEffect(() => {
if (!serverPagination || !serverPaginationData || !rowCount) return;
const nextOwnState = { ...serverPaginationData };
let changed = false;
const currentPage = serverPaginationData.currentPage ?? 0;
const currentPageSize = serverPaginationData.pageSize ?? serverPageLength;
const totalPages = Math.ceil(rowCount / currentPageSize);
if (serverPagination && serverPaginationData && rowCount !== undefined) {
const currentPage = serverPaginationData.currentPage ?? 0;
const currentPageSize = serverPaginationData.pageSize ?? serverPageLength;
const totalPages = Math.ceil(rowCount / currentPageSize);
// An empty result set clamps to page zero; a shrunken one clamps to its
// last remaining page.
const clampedPage = Math.max(0, Math.min(currentPage, totalPages - 1));
if (clampedPage !== currentPage) {
nextOwnState.currentPage = clampedPage;
changed = true;
}
}
if (currentPage >= totalPages && totalPages > 0) {
const validPage = Math.max(0, totalPages - 1);
const modifiedOwnState = {
...serverPaginationData,
currentPage: validPage,
};
updateTableOwnState(setDataMask, modifiedOwnState);
const primed = (serverPaginationData?.rawSummaryColumns ?? []) as string[];
const requested = Boolean(serverPaginationData?.totalsRequested);
if (isRawRecords && showTotals && !isEqual(primed, rawSummaryColumns)) {
nextOwnState.rawSummaryColumns = rawSummaryColumns;
changed = true;
}
// A renderTrigger toggle re-renders without re-querying; requesting totals
// through ownState dispatches the standard re-query whose buildQuery
// carries the totals query for the active mode.
if (showTotals && totals === undefined && !requested) {
nextOwnState.totalsRequested = true;
changed = true;
} else if (!showTotals && requested) {
nextOwnState.totalsRequested = false;
changed = true;
}
if (changed) {
updateTableOwnState(setDataMask, nextOwnState);
}
}, [
rowCount,
serverPagination,
serverPaginationData,
rowCount,
serverPageLength,
isRawRecords,
showTotals,
totals,
rawSummaryColumns,
serverPaginationData,
setDataMask,
]);
@@ -447,7 +480,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
isUsingTimeComparison ? renderTimeComparisonVisibility : () => null
}
cleanedTotals={totals || {}}
showTotals={showTotals}
showTotals={
showTotals && totals !== undefined && Object.keys(totals).length > 0
}
width={width}
onColumnStateChange={handleColumnStateChange}
chartState={chartState}

View File

@@ -624,11 +624,28 @@ const buildQuery: BuildQuery<TableChartFormData> = (
// Create totals query AFTER all filters (including AG Grid filters) are applied
// This ensures we can properly exclude AG Grid WHERE filters from the totals
if (
// In raw records mode the summary is a SUM over the numeric columns primed
// into ownState by the chart (see rawSummaryColumns in transformProps).
// Own state can outlive a datasource or column-selection change, so bound
// the primed summary columns to the current raw selection: a stale name
// must never reach a SUM metric or the whole chart query fails before the
// chart can re-prime its own state.
const selectedRawColumns = new Set(
ensureIsArray(formData.all_columns).map(getColumnLabel),
);
const rawSummaryColumns =
queryMode === QueryMode.Raw && formData.show_totals
? ensureIsArray(
ownState.rawSummaryColumns as string[] | undefined,
).filter(columnName => selectedRawColumns.has(columnName))
: [];
const showAggregateTotals = Boolean(
metrics?.length &&
formData.show_totals &&
queryMode === QueryMode.Aggregate
) {
queryMode === QueryMode.Aggregate,
);
if (showAggregateTotals || rawSummaryColumns.length > 0) {
// Create a copy of extras without the AG Grid WHERE clause
// AG Grid filters in extras.where can reference calculated columns
// which aren't available in the totals subquery
@@ -661,6 +678,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
extraQueries.push({
...queryObject,
columns: [],
...(rawSummaryColumns.length > 0 && {
metrics: rawSummaryColumns.map(columnName => ({
expressionType: 'SIMPLE' as const,
aggregate: 'SUM' as const,
column: { column_name: columnName },
label: columnName,
})),
}),
extras: totalsExtras, // Use extras with AG Grid WHERE removed
row_limit: 0,
row_offset: 0,

View File

@@ -425,21 +425,6 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'show_totals',
config: {
type: 'CheckboxControl',
label: t('Show summary'),
default: false,
description: t(
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
),
visibility: isAggMode,
resetOnHide: false,
},
},
],
],
},
{
@@ -496,6 +481,20 @@ const config: ControlPanelConfig = {
label: t('Visual formatting'),
expanded: true,
controlSetRows: [
[
{
name: 'show_totals',
config: {
type: 'CheckboxControl',
label: t('Show summary'),
default: false,
renderTrigger: true,
description: t(
'Show a summary row of total aggregations: the selected metrics in aggregate mode, or the sum of numeric columns in raw records mode. Note that row limit does not apply to the result.',
),
},
},
],
[
{
name: 'show_numbered_column',

View File

@@ -714,12 +714,11 @@ const transformProps = (
const hasPageLength = isPositiveNumber(pageLength);
const totals =
showTotals && queryMode === QueryMode.Aggregate
? isUsingTimeComparison
? processComparisonTotals(comparisonSuffix, totalQuery?.data)
: totalQuery?.data[0]
: undefined;
const totals = showTotals
? isUsingTimeComparison
? processComparisonTotals(comparisonSuffix, totalQuery?.data)
: totalQuery?.data[0]
: undefined;
// Map saved metric/calculated column labels to their SQL expressions for filter resolution
const metricSqlExpressions: Record<string, string> = {};
@@ -737,6 +736,21 @@ const transformProps = (
}
});
// Numeric raw-records columns eligible for the summary row. Only columns
// backed by a dataset (physical or calculated) column can be summed
// server-side; free-form SQL expression columns are excluded.
const datasetColumnNames = new Set(
chartProps.datasource.columns
.map(col => col.column_name)
.filter((name): name is string => Boolean(name)),
);
const rawSummaryColumns =
queryMode === QueryMode.Raw && showTotals
? columns
.filter(col => col.isNumeric && datasetColumnNames.has(col.key))
.map(col => col.key)
: [];
// Strip saved filter from chartState after initial application to prevent re-injection
let chartState = serverPaginationData?.chartState as
| AgGridChartState
@@ -784,6 +798,7 @@ const transformProps = (
basicColorFormatters,
formData,
metricSqlExpressions,
rawSummaryColumns,
chartState,
onChartStateChange,
showNumberedColumn,

View File

@@ -130,6 +130,7 @@ export interface AgGridTableChartTransformedProps<
basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[];
formData: TableChartFormData;
metricSqlExpressions: Record<string, string>;
rawSummaryColumns: string[];
onChartStateChange?: (chartState: JsonObject) => void;
chartState?: AgGridChartState;
showNumberedColumn: boolean;

View File

@@ -27,6 +27,8 @@ interface TableOwnState {
sortOrder?: 'asc' | 'desc';
searchText?: string;
sortBy?: SortByItem[];
rawSummaryColumns?: string[];
totalsRequested?: boolean;
}
export const updateTableOwnState = (

View File

@@ -19,6 +19,7 @@
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@superset-ui/core/spec';
import { QueryMode, TimeGranularity, SMART_DATE_ID } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import AgGridTableChart from '../src/AgGridTableChart';
import transformProps from '../src/transformProps';
@@ -326,6 +327,61 @@ test('AgGridTableChart handles raw records mode', async () => {
expect(headerCells.length).toBeGreaterThan(0);
});
const rawSummaryProps = {
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
query_mode: QueryMode.Raw,
show_totals: true,
},
datasource: {
...testData.basic.datasource,
columns: [{ column_name: 'name' }, { column_name: 'sum__num' }],
},
queriesData: [
{
...testData.basic.queriesData[0],
colnames: [...testData.basic.queriesData[0].colnames, 'num_free'],
coltypes: [
...testData.basic.queriesData[0].coltypes,
GenericDataType.Numeric,
],
data: testData.basic.queriesData[0].data,
},
{ ...testData.basic.queriesData[0], data: [{ sum__num: 12345 }] },
],
};
test('transformProps derives numeric dataset columns as rawSummaryColumns in raw mode', () => {
const transformed = transformProps(rawSummaryProps);
// 'sum__num' is the only numeric column of the selection that is both
// numeric and backed by a dataset column: 'num_free' is Numeric but not
// dataset-backed, and 'name' is dataset-backed but not numeric.
expect(transformed.rawSummaryColumns).toEqual(['sum__num']);
});
test('transformProps surfaces raw records totals when the summary is on', () => {
const transformed = transformProps(rawSummaryProps);
expect(transformed.totals).toEqual({ sum__num: 12345 });
});
test('transformProps leaves totals undefined in raw mode when the summary is off', () => {
const transformed = transformProps({
...rawSummaryProps,
rawFormData: {
...rawSummaryProps.rawFormData,
show_totals: false,
},
});
expect(transformed.totals).toBeUndefined();
expect(transformed.rawSummaryColumns).toEqual([]);
});
test('transformProps returns empty rawSummaryColumns in aggregate mode', () => {
const transformed = transformProps(testData.basic);
expect(transformed.rawSummaryColumns).toEqual([]);
});
test('AgGridTableChart corrects invalid page number when currentPage >= totalPages', async () => {
const props = transformProps({
...testData.basic,
@@ -357,3 +413,316 @@ test('AgGridTableChart corrects invalid page number when currentPage >= totalPag
expect(mockSetDataMask).toHaveBeenCalled();
});
});
test('AgGridTableChart primes raw summary columns into own state', async () => {
const props = transformProps(rawSummaryProps);
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({
rawSummaryColumns: ['sum__num'],
}),
}),
);
});
});
test('AgGridTableChart does not prime summary columns when the summary is off', async () => {
const props = transformProps({
...rawSummaryProps,
rawFormData: {
...rawSummaryProps.rawFormData,
show_totals: false,
},
});
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(document.querySelector('.ag-container')).toBeInTheDocument();
});
const primedCalls = mockSetDataMask.mock.calls.filter(
([arg]) => arg?.ownState?.rawSummaryColumns,
);
expect(primedCalls).toHaveLength(0);
});
test('AgGridTableChart clears a stale summary prime when no numeric columns remain', async () => {
const props = transformProps({
...rawSummaryProps,
datasource: {
...testData.basic.datasource,
columns: [{ column_name: 'name' }],
},
});
props.serverPaginationData = { rawSummaryColumns: ['sum__num'] };
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({ rawSummaryColumns: [] }),
}),
);
});
});
test('AgGridTableChart requests aggregate totals when the summary toggles on without data', async () => {
const props = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
show_totals: true,
},
});
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({ totalsRequested: true }),
}),
);
});
});
test('AgGridTableChart clears the aggregate totals request when the summary is off', async () => {
const props = transformProps(testData.basic);
props.serverPaginationData = { totalsRequested: true };
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({
totalsRequested: false,
}),
}),
);
});
});
test('AgGridTableChart clamps the page to zero when the result set is empty', async () => {
const props = transformProps({
...rawSummaryProps,
queriesData: [rawSummaryProps.queriesData[0]],
});
props.serverPagination = true;
props.rowCount = 0;
props.serverPaginationData = { currentPage: 5, pageSize: 20 };
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
// Zero rows means zero pages; a stale page index must reset so row_offset
// does not keep pointing past the (now empty) result set. Asserting the
// totals keys alongside pins the write to the unified effect rather than
// the pagination footer's own page reset.
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({
currentPage: 0,
rawSummaryColumns: ['sum__num'],
totalsRequested: true,
}),
}),
);
});
});
test('AgGridTableChart merges the page clamp and totals request into one own-state write', async () => {
const props = transformProps({
...rawSummaryProps,
queriesData: [rawSummaryProps.queriesData[0]],
});
props.serverPagination = true;
props.rowCount = 41;
props.serverPaginationData = { currentPage: 5, pageSize: 20 };
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
// 41 rows at page size 20 leave 3 pages, so page 5 must clamp to 2 in the
// same write that primes the summary columns and requests totals; separate
// writes would overwrite each other's keys.
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({
currentPage: 2,
rawSummaryColumns: ['sum__num'],
totalsRequested: true,
}),
}),
);
});
});
test('AgGridTableChart re-requests raw totals when toggled back on with a matching prime', async () => {
const props = transformProps({
...rawSummaryProps,
queriesData: [rawSummaryProps.queriesData[0]],
});
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
serverPaginationData={{ rawSummaryColumns: ['sum__num'] }}
/>
),
}),
);
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({
rawSummaryColumns: ['sum__num'],
totalsRequested: true,
}),
}),
);
});
});
test('AgGridTableChart pins no summary row when totals come back empty', async () => {
const props = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
show_totals: true,
},
});
props.showTotals = true;
// An empty totals object (e.g. a time-comparison totals result with no
// rows) carries nothing to display and must not pin a blank row.
props.totals = {};
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(document.querySelector('.ag-container')).toBeInTheDocument();
});
const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row');
expect(pinnedRows.length).toBe(0);
});
test('AgGridTableChart pins no summary row when totals are absent', async () => {
const props = transformProps({
...rawSummaryProps,
datasource: {
...testData.basic.datasource,
columns: [{ column_name: 'name' }],
},
queriesData: [rawSummaryProps.queriesData[0]],
});
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(document.querySelector('.ag-container')).toBeInTheDocument();
});
const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row');
expect(pinnedRows.length).toBe(0);
});

View File

@@ -1432,4 +1432,133 @@ describe('plugin-chart-ag-grid-table', () => {
expect(query.extras?.where || undefined).toBeUndefined();
});
});
describe('buildQuery - raw records summary totals', () => {
const rawFormData: TableChartFormData = {
viz_type: VizType.Table,
datasource: '11__table',
query_mode: QueryMode.Raw,
all_columns: ['name', 'num'],
show_totals: true,
};
test('drops summary columns missing from the raw selection', () => {
// Own state can outlive a datasource or column-selection change; a
// persisted name absent from all_columns must never reach a SUM metric.
const { queries } = buildQuery(rawFormData, {
ownState: { rawSummaryColumns: ['num', 'ghost_col'] },
});
expect(queries).toHaveLength(2);
expect(queries[1].metrics).toEqual([
{
expressionType: 'SIMPLE',
aggregate: 'SUM',
column: { column_name: 'num' },
label: 'num',
},
]);
});
test('keeps calculated dataset columns in the totals metrics', () => {
// Calculated columns are dataset-backed and resolve server-side from
// the column name alone (standard SIMPLE adhoc-metric resolution); the
// selection intersection must not assume physical columns only.
const { queries } = buildQuery(
{ ...rawFormData, all_columns: ['name', 'num', 'boys_ratio_calc'] },
{ ownState: { rawSummaryColumns: ['num', 'boys_ratio_calc'] } },
);
expect(queries).toHaveLength(2);
expect(queries[1].metrics).toEqual([
{
expressionType: 'SIMPLE',
aggregate: 'SUM',
column: { column_name: 'num' },
label: 'num',
},
{
expressionType: 'SIMPLE',
aggregate: 'SUM',
column: { column_name: 'boys_ratio_calc' },
label: 'boys_ratio_calc',
},
]);
});
test('adds no totals query when every primed column left the selection', () => {
const { queries } = buildQuery(rawFormData, {
ownState: { rawSummaryColumns: ['ghost_col'] },
});
expect(queries).toHaveLength(1);
});
test('adds a SUM totals query when summary columns are primed', () => {
const { queries } = buildQuery(rawFormData, {
ownState: { rawSummaryColumns: ['num'] },
});
expect(queries).toHaveLength(2);
expect(queries[1]).toEqual(
expect.objectContaining({
columns: [],
row_limit: 0,
row_offset: 0,
metrics: [
{
expressionType: 'SIMPLE',
aggregate: 'SUM',
column: { column_name: 'num' },
label: 'num',
},
],
}),
);
expect(queries[1].orderby).toBeUndefined();
});
test('adds no totals query without primed summary columns', () => {
const { queries } = buildQuery(rawFormData, { ownState: {} });
expect(queries).toHaveLength(1);
});
test('adds no totals query when show_totals is off', () => {
const { queries } = buildQuery(
{ ...rawFormData, show_totals: false },
{ ownState: { rawSummaryColumns: ['num'] } },
);
expect(queries).toHaveLength(1);
});
test('keeps the totals query last with server pagination', () => {
const { queries } = buildQuery(
{ ...rawFormData, server_pagination: true },
{ ownState: { rawSummaryColumns: ['num'] } },
);
expect(queries).toHaveLength(3);
expect(queries[1].is_rowcount).toBe(true);
expect(queries[2].columns).toEqual([]);
expect(queries[2].row_limit).toBe(0);
});
test('keeps aggregate-mode totals metrics untouched', () => {
const { queries } = buildQuery(
{
viz_type: VizType.Table,
datasource: '11__table',
query_mode: QueryMode.Aggregate,
groupby: ['state'],
metrics: ['count'],
show_totals: true,
},
{ ownState: {} },
);
expect(queries).toHaveLength(2);
expect(queries[1].columns).toEqual([]);
expect(queries[1].metrics).toEqual(['count']);
});
});
});

View File

@@ -16,6 +16,7 @@ import {
ControlPanelsContainerProps,
ControlState,
CustomControlItem,
isCustomControlItem,
} from '@superset-ui/chart-controls';
import config from '../src/controlPanel';
@@ -75,3 +76,38 @@ test('time_grain_sqla visibility should be case-insensitive', () => {
expect(vis(mkProps(['ORDERDATE']), controlState)).toBe(true);
expect(vis(mkProps(['some_other_col']), controlState)).toBe(false);
});
test('show_totals renders in the customize tab atop visual formatting', () => {
const visualFormatting = config.controlPanelSections.find(
section => section?.label === 'Visual formatting',
);
expect(visualFormatting).toBeDefined();
const [firstRow] = visualFormatting!.controlSetRows;
const firstControl = firstRow[0] as CustomControlItem;
expect(firstControl.name).toBe('show_totals');
// renderTrigger keeps the whole section classified into the customize tab
// and must not regress; without it the section moves to the data tab.
expect(firstControl.config.renderTrigger).toBe(true);
// No visibility gate: the summary checkbox must render in raw records
// mode as well as aggregate mode.
expect(firstControl.config.visibility).toBeUndefined();
});
test('every Visual formatting control is a renderTrigger', () => {
// A non-renderTrigger control in this section would silently drag the
// whole section's classification from the customize tab to the data tab.
const visualFormatting = config.controlPanelSections.find(
section => section?.label === 'Visual formatting',
);
expect(visualFormatting).toBeDefined();
const controls = visualFormatting!.controlSetRows
.flat()
.filter(isCustomControlItem);
expect(controls.length).toBeGreaterThan(0);
controls.forEach(control => {
expect(control.config.renderTrigger).toBe(true);
});
});

View File

@@ -32,7 +32,7 @@
"@types/geojson": "^7946.0.16",
"geojson": "^0.5.0",
"lodash": "^4.18.1",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",

View File

@@ -33,8 +33,9 @@ import { getLayerConfig } from '../util/controlPanelUtil';
export default class CartodiagramPlugin extends ChartPlugin {
constructor(opts: CartodiagramPluginConstructorOpts) {
const metadata = new ChartMetadata({
description:
description: t(
'Display charts on a map. For using this plugin, users first have to create any other chart that can then be placed on the map.',
),
name: t('Cartodiagram'),
thumbnail,
thumbnailDark,

View File

@@ -30,7 +30,7 @@
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"zod": "^4.4.3",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@apache-superset/core": "*",

View File

@@ -28,8 +28,9 @@ export default class PopKPIPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
category: t('KPI'),
description:
description: t(
'Showcases a metric along with a comparison of value, change, and percent change for a selected time period.',
),
name: t('Big Number with Time Period Comparison'),
tags: [
t('Comparison'),

View File

@@ -282,6 +282,11 @@ export default function transformProps(
? formatTime
: numberFormatter;
const lineWidth = 2;
// Pad the grid by half the stroke width so the trendline isn't clipped at
// the edges of the chart area (the stroke extends beyond the data point).
const strokePad = lineWidth / 2;
const echartOptions: EChartsCoreOption = trendLineData
? {
series: [
@@ -293,6 +298,9 @@ export default function transformProps(
symbolSize: 10,
showSymbol: false,
color: mainColor ?? BRAND_COLOR,
lineStyle: {
width: lineWidth,
},
areaStyle: {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
@@ -346,10 +354,10 @@ export default function transformProps(
top: TIMESERIES_CONSTANTS.gridOffsetTop,
}
: {
bottom: 0,
left: 0,
right: 0,
top: 0,
bottom: strokePad,
left: strokePad,
right: strokePad,
top: strokePad,
},
tooltip: {
...getDefaultTooltip(refs),

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { DatasourceType, TimeGranularity, VizType } from '@superset-ui/core';
import type { LineSeriesOption } from 'echarts';
import { supersetTheme } from '@apache-superset/core/theme';
import transformProps from '../../src/BigNumber/BigNumberWithTrendline/transformProps';
import {
@@ -269,11 +270,18 @@ describe('BigNumberWithTrendline', () => {
},
});
const series = (
transformed.echartOptions?.series as LineSeriesOption[]
)?.[0];
const lineWidth = series?.lineStyle?.width;
expect(lineWidth).toBe(2);
const expectedPad = (lineWidth as number) / 2;
expect(transformed.echartOptions?.grid).toEqual({
bottom: 0,
left: 0,
right: 0,
top: 0,
bottom: expectedPad,
left: expectedPad,
right: expectedPad,
top: expectedPad,
});
});

View File

@@ -30,7 +30,7 @@
"currencyformatter.js": "^1.0.5",
"handlebars-group-by": "^1.0.1",
"just-handlebars-helpers": "^1.0.19",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@superset-ui/chart-controls": "*",

View File

@@ -33,7 +33,7 @@
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",

View File

@@ -1204,8 +1204,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
onClick:
emitCrossFilters && !valueRange && !isMetric
? () => {
const isFilterable = columnsMeta.find(
(cm: DataColumnMeta) => cm.key === key,
)?.isFilterable;
// allow selecting text in a cell
if (!getSelectedText()) {
if (!getSelectedText() && isFilterable !== false) {
toggleFilter(key, value);
}
}

View File

@@ -232,6 +232,9 @@ const processColumns = memoizeOne(function processColumns(
const metricsSet = new Set(metrics);
const percentMetricsSet = new Set(percentMetrics);
const rawPercentMetricsSet = new Set(rawPercentMetrics);
const columnsByName = new Map(
(props.datasource.columns ?? []).map(col => [col.column_name, col]),
);
const columns: DataColumnMeta[] = (colnames || [])
.filter(
@@ -244,6 +247,7 @@ const processColumns = memoizeOne(function processColumns(
const config = columnConfig[key] || {};
// for the purpose of presentation, only numeric values are treated as metrics
// because users can also add things like `MAX(str_col)` as a metric.
const isFilterable = columnsByName.get(key)?.filterable;
const isMetric = metricsSet.has(key) && isNumeric(key, records);
const isPercentMetric = percentMetricsSet.has(key);
const label =
@@ -326,6 +330,7 @@ const processColumns = memoizeOne(function processColumns(
isPercentMetric,
formatter,
config,
isFilterable,
description,
currencyCodeColumn,
};

View File

@@ -2534,3 +2534,33 @@ test('sorts genuinely string columns alphanumerically', () => {
const values = Array.from(cells).map(td => td.textContent);
expect(values).toEqual(['apple', 'banana', 'cherry']);
});
test('TableChart should NOT emit cross-filter when clicking a cell in a not-filterable column', () => {
const setDataMask = jest.fn();
const props = transformProps({
...testData.basic,
datasource: {
...testData.basic.datasource,
columns: [{ column_name: 'name', filterable: false } as any],
},
hooks: { setDataMask },
emitCrossFilters: true,
});
render(
<ProviderWrapper>
<TableChart
{...props}
emitCrossFilters
setDataMask={setDataMask}
sticky={false}
/>
</ProviderWrapper>,
);
fireEvent.click(screen.getByText('Michael'));
const crossFilterCall = setDataMask.mock.calls.find(
(call: any[]) => call[0]?.filterState?.filters,
);
expect(crossFilterCall).toBeUndefined();
});

View File

@@ -32,7 +32,7 @@
"@types/d3-scale": "^4.0.9",
"d3-cloud": "^1.2.9",
"d3-scale": "^4.0.2",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@apache-superset/core": "*",

View File

@@ -29,7 +29,7 @@
"@deck.gl/extensions": "~9.2.9",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mapbox": "~9.3.4",
"@deck.gl/mapbox": "~9.3.5",
"@deck.gl/mesh-layers": "~9.2.5",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
@@ -54,7 +54,7 @@
"underscore": "^1.13.7",
"urijs": "^1.19.11",
"xss": "^1.0.15",
"lodash-es": "^4.17.21"
"lodash-es": "^4.18.1"
},
"devDependencies": {
"@types/mapbox__geojson-extent": "^1.0.3",

View File

@@ -0,0 +1,68 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
import { getCategories } from './CategoricalDeckGLContainer';
import { addColorToFeatures } from './utils/addColor';
import { COLOR_SCHEME_TYPES } from './utilities/utils';
// Record every (label, sliceId) pair the categorical color scale is asked to
// resolve, so we can assert the legend and point-color paths key the scale on
// the same slice id.
const scaleCalls: [string, number | undefined][] = [];
jest.mock('@superset-ui/core', () => {
const actual = jest.requireActual('@superset-ui/core');
return {
...actual,
CategoricalColorNamespace: {
...actual.CategoricalColorNamespace,
getScale: () => (value: string, sliceId?: number) => {
scaleCalls.push([value, sliceId]);
return value === 'A' ? '#ff0000' : '#00ff00';
},
},
};
});
test('legend and point colors resolve from the same slice_id', () => {
const fd = {
datasource: '1__table',
viz_type: 'deck_scatter',
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
color_scheme: 'supersetColors',
dimension: 'category',
slice_id: 42,
color_picker: { r: 0, g: 0, b: 0, a: 1 },
} as unknown as QueryFormData;
const data = [{ cat_color: 'A' }, { cat_color: 'B' }];
const categories = getCategories(fd, data);
const features = addColorToFeatures(data, fd);
// Both the legend path (getCategories) and the point-color path
// (addColorToFeatures) key the color scale on the same slice id.
expect(scaleCalls.length).toBeGreaterThan(0);
scaleCalls.forEach(([, sliceId]) => {
expect(sliceId).toBe(42);
});
// The legend swatch for each category matches the resolved point color.
expect(categories.A.color).toEqual(features[0].color);
expect(categories.B.color).toEqual(features[1].color);
expect(features[0].color).not.toEqual(features[1].color);
});

View File

@@ -47,15 +47,15 @@ import {
DeckGLContainerStyledWrapper,
} from './DeckGLContainer';
import { GetLayerType } from './factory';
import { ColorBreakpointType, ColorType, Point } from './types';
import { Point } from './types';
import { TooltipProps } from './components/Tooltip';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from './utilities/utils';
import { getColorBreakpointsBuckets } from './utils';
import { DEFAULT_DECKGL_COLOR } from './utilities/Shared_DeckGL';
import { addColorToFeatures } from './utils/addColor';
const { getScale } = CategoricalColorNamespace;
function getCategories(fd: QueryFormData, data: JsonObject[]) {
export function getCategories(fd: QueryFormData, data: JsonObject[]) {
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
const appliedScheme = fd.color_scheme;
@@ -70,7 +70,7 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) {
if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) {
let color;
if (fd.dimension) {
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255);
color = hexToRGB(colorFn(d.cat_color, fd.slice_id), c.a * 255);
} else {
color = fixedColor;
}
@@ -150,80 +150,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
data: JsonObject[],
fd: QueryFormData,
selectedColorScheme: ColorSchemeType,
) => {
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
let color: ColorType;
switch (selectedColorScheme) {
case COLOR_SCHEME_TYPES.fixed_color: {
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorArray = [color.r, color.g, color.b, color.a * 255];
return data.map(d => ({ ...d, color: colorArray }));
}
case COLOR_SCHEME_TYPES.categorical_palette: {
if (!fd.dimension) {
const fallbackColor = fd.color_picker || {
r: 0,
g: 0,
b: 0,
a: 1,
};
const colorArray = [
fallbackColor.r,
fallbackColor.g,
fallbackColor.b,
fallbackColor.a * 255,
];
return data.map(d => ({ ...d, color: colorArray }));
}
return data.map(d => ({
...d,
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
}));
}
case COLOR_SCHEME_TYPES.color_breakpoints: {
const defaultBreakpointColor = fd.default_breakpoint_color
? [
fd.default_breakpoint_color.r,
fd.default_breakpoint_color.g,
fd.default_breakpoint_color.b,
fd.default_breakpoint_color.a * 255,
]
: [
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
];
return data.map(d => {
const breakpointForPoint: ColorBreakpointType =
fd.color_breakpoints?.find(
(breakpoint: ColorBreakpointType) =>
d.metric >= breakpoint.minValue &&
d.metric <= breakpoint.maxValue,
);
if (breakpointForPoint) {
const pointColor = [
breakpointForPoint.color.r,
breakpointForPoint.color.g,
breakpointForPoint.color.b,
breakpointForPoint.color.a * 255,
];
return { ...d, color: pointColor };
}
return { ...d, color: defaultBreakpointColor };
});
}
default: {
return [];
}
}
},
) => addColorToFeatures(data, fd, selectedColorScheme),
[],
);

View File

@@ -0,0 +1,245 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { DatasourceType, SupersetClient } from '@superset-ui/core';
import DeckMulti from './Multi';
// Capture the layers handed to the DeckGL container so we can inspect the
// per-feature colors that were resolved for each sublayer.
interface CapturedDataPoint {
color: number[];
}
interface CapturedLayer {
id?: string;
props: {
data: CapturedDataPoint[];
getSourceColor?: (d: Record<string, unknown>) => number[];
getTargetColor?: (d: Record<string, unknown>) => number[];
};
}
const mockLayerCapture: { layers: CapturedLayer[] } = { layers: [] };
jest.mock('../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: ({ layers }: { layers?: CapturedLayer[] }) => {
mockLayerCapture.layers = layers || [];
return <div data-test="deckgl-container">DeckGL Container Mock</div>;
},
}));
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
get: jest.fn(),
},
}));
const mockStore = configureStore({
reducer: {
dataMask: () => ({}),
},
});
const renderWithProviders = (component: React.ReactElement) =>
render(
<Provider store={mockStore}>
<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>
</Provider>,
);
const SCATTER_SLICE_ID = 1;
const props = {
formData: {
datasource: '1__table',
viz_type: 'deck_multi',
deck_slices: [SCATTER_SLICE_ID],
autozoom: false,
map_style: 'mapbox://styles/mapbox/light-v9',
},
payload: {
data: {
slices: [
{
slice_id: SCATTER_SLICE_ID,
form_data: {
viz_type: 'deck_scatter',
datasource: '1__table',
slice_id: SCATTER_SLICE_ID,
// categorical color configuration coming from the saved scatter chart
color_scheme_type: 'categorical_palette',
color_scheme: 'supersetColors',
dimension: 'category',
},
},
],
features: {
deck_scatter: [],
},
mapboxApiKey: 'test-key',
},
},
setControlValue: jest.fn(),
viewport: { longitude: 0, latitude: 0, zoom: 1 },
onAddFilter: jest.fn(),
height: 600,
width: 800,
datasource: {
id: 1,
type: DatasourceType.Table,
name: 'test_datasource',
columns: [],
metrics: [],
columnFormats: {},
currencyFormats: {},
verboseMap: {},
},
onSelect: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockLayerCapture.layers = [];
// The scatter sublayer query returns features tagged with a category column.
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: {
data: {
features: [
{ position: [0, 0], radius: 1, cat_color: 'A' },
{ position: [1, 1], radius: 1, cat_color: 'B' },
],
},
},
});
});
const expectDistinctCategoricalColors = async () => {
await waitFor(() => {
expect(mockLayerCapture.layers.length).toBeGreaterThan(0);
});
const scatterLayer = mockLayerCapture.layers.find((layer: CapturedLayer) =>
layer?.id?.startsWith('scatter-layer-'),
);
expect(scatterLayer).toBeDefined();
const { data } = (scatterLayer as CapturedLayer).props;
expect(data).toHaveLength(2);
// Both points must carry a resolved RGBA color...
data.forEach((d: CapturedDataPoint) => {
expect(Array.isArray(d.color)).toBe(true);
expect(d.color).toHaveLength(4);
});
// ...and the two distinct categories must NOT share the same color. Before
// the fix, categorical colors were dropped in the Multiple Layers chart and
// every point fell back to the same default color.
expect(data[0].color).not.toEqual(data[1].color);
};
test('applies categorical scatterplot colors to sublayers in the multi chart', async () => {
renderWithProviders(<DeckMulti {...props} />);
await expectDistinctCategoricalColors();
});
test('applies categorical colors to scatter subslices saved before the color_scheme_type control existed', async () => {
// Charts saved before the color_scheme_type control existed lack the key in
// stored params; the scatter default (categorical_palette) must be resolved
// so they keep per-category colors.
const legacyProps = {
...props,
payload: {
...props.payload,
data: {
...props.payload.data,
slices: [
{
slice_id: SCATTER_SLICE_ID,
form_data: {
viz_type: 'deck_scatter',
datasource: '1__table',
slice_id: SCATTER_SLICE_ID,
color_scheme: 'supersetColors',
dimension: 'category',
},
},
],
},
},
};
renderWithProviders(<DeckMulti {...legacyProps} />);
await expectDistinctCategoricalColors();
});
test('keeps fixed source and target colors for arc subslices saved before the color_scheme_type control existed', async () => {
// Legacy arcs default to fixed_color, where the layer reads the source and
// target pickers directly; resolving the default must not stamp a single
// per-feature color over the target color.
const ARC_SLICE_ID = 2;
const arcProps = {
...props,
formData: { ...props.formData, deck_slices: [ARC_SLICE_ID] },
payload: {
...props.payload,
data: {
...props.payload.data,
slices: [
{
slice_id: ARC_SLICE_ID,
form_data: {
viz_type: 'deck_arc',
datasource: '1__table',
slice_id: ARC_SLICE_ID,
color_picker: { r: 10, g: 20, b: 30, a: 1 },
target_color_picker: { r: 40, g: 50, b: 60, a: 1 },
},
},
],
features: { deck_arc: [] },
},
},
};
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: {
data: {
features: [{ sourcePosition: [0, 0], targetPosition: [1, 1] }],
},
},
});
renderWithProviders(<DeckMulti {...arcProps} />);
await waitFor(() => {
expect(mockLayerCapture.layers.length).toBeGreaterThan(0);
});
const arcLayer = mockLayerCapture.layers.find(
(layer: CapturedLayer) => layer?.id === `path-layer-${ARC_SLICE_ID}`,
);
expect(arcLayer).toBeDefined();
expect(arcLayer?.props.getSourceColor?.({})).toEqual([10, 20, 30, 255]);
expect(arcLayer?.props.getTargetColor?.({})).toEqual([40, 50, 60, 255]);
});

View File

@@ -49,6 +49,8 @@ import {
DeckGLContainerStyledWrapper,
} from '../DeckGLContainer';
import { getExploreLongUrl } from '../utils/explore';
import { addColorToFeatures } from '../utils/addColor';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils';
import layerGenerators from '../layers';
import fitViewport, { Viewport } from '../utils/fitViewport';
import { getMapboxApiKey } from '../utils/mapbox';
@@ -98,6 +100,16 @@ const MultiWrapper = styled.div<{ height: number; width: number }>`
width: ${({ width }) => width}px;
`;
// Default color_scheme_type per color-aware layer type, matching each control
// panel. Sub-slices arrive as raw saved form data without control-default
// hydration, so charts saved before this control existed need the default
// resolved here to keep their configured colors.
const COLOR_AWARE_LAYER_DEFAULTS: Record<string, ColorSchemeType> = {
deck_scatter: COLOR_SCHEME_TYPES.categorical_palette,
deck_path: COLOR_SCHEME_TYPES.fixed_color,
deck_arc: COLOR_SCHEME_TYPES.fixed_color,
};
const selectDataMask = createSelector(
(state: { dataMask?: DataMaskState }) => state.dataMask,
dataMask => dataMask || {},
@@ -225,15 +237,43 @@ const DeckMulti = (props: DeckMultiProps) => {
);
const createLayerFromData = useCallback(
(subslice: JsonObject, json: JsonObject): Layer =>
// @ts-expect-error TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators.
layerGenerators[subslice.form_data.viz_type]({
formData: subslice.form_data,
payload: json,
setTooltip,
datasource: props.datasource,
onSelect: props.onSelect,
}),
(subslice: JsonObject, json: JsonObject): Layer => {
const { form_data: subsliceFormData } = subslice;
const defaultColorSchemeType =
COLOR_AWARE_LAYER_DEFAULTS[subsliceFormData.viz_type];
let layerFormData = subsliceFormData;
let payload = json;
// Resolve per-feature colors as CategoricalDeckGLContainer does when
// the layer renders standalone.
if (defaultColorSchemeType) {
layerFormData = {
...subsliceFormData,
color_scheme_type:
subsliceFormData.color_scheme_type ?? defaultColorSchemeType,
};
if (Array.isArray(json?.data?.features)) {
payload = {
...json,
data: {
...json.data,
features: addColorToFeatures(json.data.features, layerFormData),
},
};
}
}
return (
// @ts-expect-error TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators.
layerGenerators[layerFormData.viz_type]({
formData: layerFormData,
payload,
setTooltip,
datasource: props.datasource,
onSelect: props.onSelect,
})
);
},
[props.onSelect, props.datasource, setTooltip],
);

View File

@@ -0,0 +1,103 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
import { addColorToFeatures } from './addColor';
import { COLOR_SCHEME_TYPES } from '../utilities/utils';
const baseFormData = {
datasource: '1__table',
viz_type: 'deck_scatter',
} as unknown as QueryFormData;
test('assigns distinct colors per category for a categorical palette', () => {
const features = [{ cat_color: 'A' }, { cat_color: 'B' }, { cat_color: 'A' }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
color_scheme: 'supersetColors',
dimension: 'category',
slice_id: 1,
} as unknown as QueryFormData);
// Each feature gets a resolved RGBA color
result.forEach(d => {
expect(Array.isArray(d.color)).toBe(true);
expect(d.color).toHaveLength(4);
});
// Same category resolves to the same color, different categories differ
expect(result[0].color).toEqual(result[2].color);
expect(result[0].color).not.toEqual(result[1].color);
});
test('falls back to the fixed color picker when no dimension is set', () => {
const features = [{ cat_color: 'A' }, { cat_color: 'B' }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
color_picker: { r: 10, g: 20, b: 30, a: 1 },
} as unknown as QueryFormData);
result.forEach(d => {
expect(d.color).toEqual([10, 20, 30, 255]);
});
});
test('applies the fixed color scheme to every feature', () => {
const features = [{ cat_color: 'A' }, { cat_color: 'B' }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: COLOR_SCHEME_TYPES.fixed_color,
color_picker: { r: 1, g: 2, b: 3, a: 0.5 },
} as unknown as QueryFormData);
result.forEach(d => {
expect(d.color).toEqual([1, 2, 3, 127.5]);
});
});
test('assigns breakpoint colors by metric and falls back to the default', () => {
const features = [{ metric: 5 }, { metric: 50 }, { metric: 500 }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
color_breakpoints: [
{ minValue: 0, maxValue: 10, color: { r: 1, g: 2, b: 3, a: 1 } },
{ minValue: 11, maxValue: 100, color: { r: 4, g: 5, b: 6, a: 0.5 } },
],
default_breakpoint_color: { r: 7, g: 8, b: 9, a: 1 },
} as unknown as QueryFormData);
// Metric inside the first breakpoint range
expect(result[0].color).toEqual([1, 2, 3, 255]);
// Metric inside the second breakpoint range (alpha scaled to 0-255)
expect(result[1].color).toEqual([4, 5, 6, 127.5]);
// Metric outside every range falls back to the default breakpoint color
expect(result[2].color).toEqual([7, 8, 9, 255]);
});
test('returns features unchanged for an unrecognized color scheme', () => {
const features = [{ cat_color: 'A' }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: 'something_else',
} as unknown as QueryFormData);
expect(result).toEqual(features);
expect(result[0].color).toBeUndefined();
});

View File

@@ -0,0 +1,111 @@
/**
* 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 {
CategoricalColorNamespace,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { hexToRGB } from './colors';
import { ColorBreakpointType } from '../types';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils';
import { DEFAULT_DECKGL_COLOR } from '../utilities/Shared_DeckGL';
const { getScale } = CategoricalColorNamespace;
/**
* Resolve the per-feature color for a deck.gl layer based on the form data's
* color scheme configuration. This mirrors the categorical/fixed/breakpoint
* color logic that `CategoricalDeckGLContainer` applies when a layer is
* rendered on its own, so that it can be reused when layers are composed
* inside the deck.gl Multiple Layers chart.
*
* Features whose color scheme is not recognized are returned unchanged so the
* layer's own fallback color logic can take over.
*/
export function addColorToFeatures(
data: JsonObject[],
fd: QueryFormData,
selectedColorScheme: ColorSchemeType = fd.color_scheme_type,
): JsonObject[] {
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
switch (selectedColorScheme) {
case COLOR_SCHEME_TYPES.fixed_color: {
const color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorArray = [color.r, color.g, color.b, color.a * 255];
return data.map(d => ({ ...d, color: colorArray }));
}
case COLOR_SCHEME_TYPES.categorical_palette: {
if (!fd.dimension) {
const fallbackColor = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorArray = [
fallbackColor.r,
fallbackColor.g,
fallbackColor.b,
fallbackColor.a * 255,
];
return data.map(d => ({ ...d, color: colorArray }));
}
return data.map(d => ({
...d,
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
}));
}
case COLOR_SCHEME_TYPES.color_breakpoints: {
const defaultBreakpointColor = fd.default_breakpoint_color
? [
fd.default_breakpoint_color.r,
fd.default_breakpoint_color.g,
fd.default_breakpoint_color.b,
fd.default_breakpoint_color.a * 255,
]
: [
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
];
return data.map(d => {
const breakpointForPoint: ColorBreakpointType =
fd.color_breakpoints?.find(
(breakpoint: ColorBreakpointType) =>
d.metric >= breakpoint.minValue &&
d.metric <= breakpoint.maxValue,
);
if (breakpointForPoint) {
const pointColor = [
breakpointForPoint.color.r,
breakpointForPoint.color.g,
breakpointForPoint.color.b,
breakpointForPoint.color.a * 255,
];
return { ...d, color: pointColor };
}
return { ...d, color: defaultBreakpointColor };
});
}
default:
return data;
}
}

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